Compare commits

..

44 Commits

Author SHA1 Message Date
gitea 3ce3911f55 feat(bezpieczenstwo): szyfrowanie lacz miedzy warstwami AES-256-GCM (PRE-16)
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Waiting to run
Testy / Build obrazu silnika B (swisseph) (push) Waiting to run
Testy / Kontrola składni wszystkich warstw (push) Waiting to run
Testy / Testy warstwy logicznej (silnik) (pull_request) Waiting to run
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Waiting to run
Testy / Build obrazu silnika B (swisseph) (pull_request) Waiting to run
Testy / Kontrola składni wszystkich warstw (pull_request) Waiting to run
Testy / Testy warstwy logicznej (silnik) (push) Has started running
Warstwy rozmawialy ze soba jawnym tekstem wewnatrz klastra. Token
miedzywarstwowy (LOG-32) mowil KTO pyta, ale nie ukrywal CZEGO dotyczy
odpowiedz — a plyna nia surowe wiersze oryginalnych baz interpretacyjnych,
czyli rdzen produktu. Kto podsluchal ruch wewnatrz sieci (drugi pod, mirror
portu na switchu, zrzut z wezla), mial je w calosci.

Nowy modul link_crypto (kopia w kazdej z trzech uslug — nie maja wspolnej
biblioteki; test pilnuje, ze kopie sa identyczne):
- AES-256-GCM na ciele kazdego zadania i odpowiedzi. GCM daje poufnosc I
  uwierzytelnienie naraz, wiec nie ma wariantu „zaszyfrowane, ale podatne na
  modyfikacje".
- DWA niezalezne klucze, po jednym na pare rozmowcow (prezentacja-logika,
  logika-dane). Przejecie klucza prezentacji nie otwiera warstwy danych, gdzie
  leza cale bazy. Z kazdego klucza lacza HKDF wyprowadza osobne podklucze na
  kierunek, wiec zadanie i odpowiedz nigdy nie szyfruja sie tym samym kluczem.
- Do materialu uwierzytelnianego (AAD) wchodza kierunek, sciezka, znacznik
  czasu i numer ramki — wiec ramki nie da sie przekleic na inny endpoint,
  odtworzyc po czasie (okno MAX_SKEW) ani przestawic w strumieniu.
- Strona serwerowa to czyste ASGI: podmienia cialo zanim zobaczy je FastAPI
  i przepuszcza odpowiedz strumieniowa kawalek po kawalku (okno postepu dziala
  dalej). Fail-closed: przy ustawionym kluczu jawne zadanie dostaje odmowe.

Najgrozniejszy blad wyszedl dopiero z PODSLUCHU prawdziwego gniazda, nie
z testow: klient BEZ klucza wysylal pytanie jawnym tekstem, ZANIM serwer zdazyl
odmowic. Odpowiedz byla chroniona, zapytanie juz nie — a to wlasnie ono niesie
sygnifikatory. Stad LINK_ENCRYPTION_REQUIRED: klient nie wysyla niczego, a
usluga nie wstaje, jesli klucza brak. Ta sama zasada co przy sekrecie logowania
— wolimy pod w CrashLoop niz usluge, ktora wstala i po cichu nie chroni niczego.

Klient prezentacji przepuszczony przez jeden punkt `_post()`: dopoki kazda
metoda skladala zadanie sama, dolozenie nowej znaczylo, ze latwo zapomniec
o tokenie albo kluczu (401 wyszedl juz raz dopiero na produkcji). Test
strukturalny rozszerzony: kazde wyjscie w dol musi miec i token, i klucz lacza,
a surowe httpx wolno tylko na sciezkach wyjetych spod szyfrowania (/health).

Weryfikacja:
- 23 testy link_crypto, w tym dowod, ze tajny opis NIE wystepuje w bajtach
  lecacych po sieci, oraz odrzucenie: obcego klucza, przestawionego bitu,
  przekleconej sciezki, przestawionej ramki, przeterminowanej koperty, urwanego
  strumienia i absurdalnej dlugosci ramki.
- E2E na prawdziwym uvicornie z proxy zrzucajacym gniazdo do pliku: przy
  wlaczonym wymaganiu tresci baz NIE MA na kablu (grep = 0) w obie strony;
  bez klucza / ze zlym kluczem — odmowa; klucz jednej pary nie otwiera drugiej.
- Calosc: logika 200 passed/1 skipped, prezentacja 25 passed.

docs/wdrozenie-pre16.md: instrukcja krok po kroku (klucze -> cert-manager ->
DNS -> merge aplikacji -> merge manifestow -> zaufanie CA -> weryfikacja),
z uzasadnieniem kolejnosci i tabela diagnostyki.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:52:18 +02:00
gitea 91644a44e3 fix(prezentacja): limit zadan po adresie klienta, nie proxy (PRE-16)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 31s
Testy / Kontrola składni wszystkich warstw (push) Successful in 21s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m53s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 32s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 20s
Po wlaczeniu TLS aplikacja stanie za Ingressem, a wtedy `request.client.host`
to adres POD-a Traefika — jednakowy dla wszystkich. Limiter wrzucalby caly ruch
do jednego wiadra 120/min i pierwsza osoba, ktora go wyklika, odcielaby
pozostalych. Cicha regresja, ktora ujawnilaby sie dopiero na produkcji.

Nowe `client_ip()` czyta adres z naglowka, ale WYLACZNIE przy TRUST_PROXY —
bo inaczej wystarczyloby dopisywac wlasny X-Forwarded-For, zeby przy kazdym
zadaniu wygladac na kogos innego i ominac limit calkowicie. Z tego samego
powodu bierzemy OSTATNI wpis listy: to jedyny, ktory dopisal nasz proxy;
wczesniejsze mogl podstawic klient, wiec nie znacza nic.

Szesc testow, w tym dwa istotne:
- podszycie sie pod X-Forwarded-For NIE resetuje wiadra przy wylaczonym
  TRUST_PROXY (inaczej baze dalo by sie pompowac bez ograniczen),
- za proxy dwa rozne adresy dostaja osobne wiadra i nie odcinaja sie nawzajem.

Oba sprawdzone celowym zepsuciem implementacji (zawsze ufaj naglowkowi +
bierz pierwszy wpis) — testy wtedy czerwienieja. 23 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:26:57 +02:00
gitea 163ace4283 feat(ui): interaktywny wybor modelu u dostawcy
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m14s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 10m19s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 46s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 27s
build / build (push) Successful in 1m18s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m45s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m2s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 23s
Uzytkownik wybiera nie tylko dostawce, ale konkretny model — Fable czy Opus
u Anthropica, gpt-4o-mini czy gpt-5 u OpenAI, cokolwiek ma pobrane lokalnie.

- app/llm/catalog.py: podpowiedzi modeli per dostawca wraz z oknem kontekstu,
  nadpisywalne przez <DOSTAWCA>_MODELS; GET /llm/models wystawia je dla UI.
- Pole modelu w UI jest TEKSTOWE z datalista, nie zamknietym <select> — konto
  moze miec dostep do modeli, o ktorych kod nie wie, a nowe wychodza szybciej,
  niz aktualizuje sie katalog. Puste pole = model domyslny dostawcy.
- static/models.js: zmiana dostawcy przelacza podpowiedzi, podmienia placeholder
  na model domyslny i pokazuje okno kontekstu wybranego modelu.
- build_provider(name, model) — model z zadania wygrywa nad konfiguracja.

WAZNE (znalezione przy tescie e2e): liczenie budzetu „maksymalny kontekst modelu"
szlo przez build_provider(), ktory WYMAGA klucza API — bez klucza budzet cicho
spadal do wartosci zapasowej i byl identyczny dla wszystkich modeli Anthropic.
Budzet zalezy wylacznie od okna kontekstu, wiec doszlo resolve_model(), ktore
rozwiazuje nazwe modelu bez budowania dostawcy. Teraz budzet realnie sie rozni:
Opus/Fable 3,48 mln znakow, Haiku 536 tys., gpt-4o-mini 438 tys., llama3.1 8 tys.

Pewnosc danych w katalogu: modele Anthropic pochodza z oficjalnej dokumentacji
API (okna i limity zgodne z limits.py); modele OpenAI to podpowiedzi, ktorych
nie weryfikowalem; lokalne zaleza od tego, co masz pobrane.

Testy: 174 passed / 1 skipped (logika) + 17 (prezentacja). Nowy test strukturalny
pilnuje, ze KAZDE wywolanie w dol niesie wybrany model i dostawce — dokladnie ta
klasa bledu zlapala brakujacy parametr przy horoskopie okresowym.
Zweryfikowane w przegladarce: przelaczanie dostawcy podmienia liste modeli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:40:19 +02:00
gitea f33cdc88f4 feat(llm): horoskop powstaje zawsze — kontynuacja, okna kontekstu, budzet max
PRZYCZYNA PUSTYCH ODPOWIEDZI NA ANTHROPICU (potwierdzona w dokumentacji API):
domyslnym modelem byl `claude-sonnet-5`, ktory przy POMINIETYM parametrze
`thinking` wlacza myslenie adaptacyjne, a `thinking.display` domyslnie jest
"omitted". Tokeny myslenia licza sie do max_tokens, wiec przy LLM_MAX_TOKENS=2000
cala tura wychodzila jako bloki `thinking` z pustym tekstem — parser filtrowal
type=="text" i zwracal pusty string. Opus 4.8 bez `thinking` nie mysli, wiec tam
objaw by nie wystapil.

Gwarancja niepustej odpowiedzi (wszyscy trzej dostawcy):
- generate() to teraz PETLA, nie pojedynczy strzal: tura -> jesli urwana na
  limicie, dopisz ture „kontynuuj" w tej samej rozmowie i sklej tekst,
- tura zlozona z samego myslenia traktowana jak urwana (nie jak pustka),
- pusta i NIE urwana -> jedna proba z podpowiedzia, dopiero potem blad,
- kontynuacja konczy sie tura UZYTKOWNIKA — Claude odrzuca prefill asystenta (400),
- `thinking` konfigurowany JAWNIE (adaptive + effort=high; ANTHROPIC_THINKING=off).

Okna kontekstu i rezerwa na odpowiedz (app/llm/limits.py):
- tabela okien/limitow wyjscia per model + nadpisanie z ENV,
- plan() liczy okno odpowiedzi jako okno - prompt - margines i NIGDY nie oddaje
  calego kontekstu promptowi,
- Anthropic liczy tokeny DOKLADNIE (/v1/messages/count_tokens), reszta szacuje,
- >90 tys. tokenow promptu -> ostrzezenie, ale wyslanie NADAL mozliwe i z pelnym
  oknem odpowiedzi.

UI: suwak budzetu rozszerzony o „bardzo obszerny" i „maksymalny kontekst modelu"
(liczony z okna wybranego modelu po odjeciu rezerwy); przy wyniku widac plan
tokenow, liczbe tur i ostrzezenia.

Domyslny model Anthropic: claude-opus-4-8.

Testy: 170 passed / 1 skipped (logika) + 15 (prezentacja). Nowe testy pokrywaja
sklejanie kontynuacji, brak prefillu asystenta, ture z samego myslenia, rezerwe
na odpowiedz i prog ostrzezenia. Zweryfikowane e2e na atrapie Anthropica
odtwarzajacej zgloszony objaw: 3 tury, obie czesci tekstu obecne.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:40:19 +02:00
gitea 877ec91ff0 fix(llm): pusta odpowiedz modelu to blad, nie pusta strona
build / build (push) Successful in 57s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 13m7s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m57s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 40s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
Objaw zgloszony przez uzytkownika: w Kalendarzu horoskop wyswietla sie
poprawnie, w Interpretacjach zapytanie wychodzi, wraca — i NIC sie nie
pokazuje. Bez zadnego komunikatu.

Przyczyna: model potrafi oddac pusta tresc (finish_reason=length,
completion_tokens=0), a generate() zwracalo wtedy pusty tekst BEZ bledu.
Widok sprawdza {% if prompt_result.horoscope %} -> falsz -> nie renderuje nic,
a llm_error nie jest ustawiony -> zero wyjasnienia. Cicha awaria.

Asymetria miedzy ekranami wynika z rozmiaru promptu: natalny (13 obiektow x
fasety x opisy z bazy) wypelnia okno kontekstu modelu lokalnego i na odpowiedz
nie zostaje miejsca; okresowy jest mniejszy i sie miesci.

- wspolny straznik _require_text() dla obu dostawcow: pusta lub bialoznakowa
  odpowiedz podnosi LLMError,
- komunikat PROWADZI DO PRZYCZYNY: podaje finish_reason i zuzycie tokenow oraz
  radzi zmniejszyc budzet promptu / zwiekszyc num_ctx / LLM_MAX_TOKENS,
- Anthropic sprowadzony do wspolnego ksztaltu diagnostyki (input/output_tokens).

Dzieki temu uzytkownik widzi powod ORAZ gotowy prompt do recznego uzycia.

Zweryfikowane na zywym stosie z atrapa modelu oddajaca pusta tresc: zamiast
pustej strony pojawia sie pelny komunikat z diagnostyka. Testy: 148 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:05:20 +00:00
gitea 487dbb8fd3 fix(ci): smoke test silnika B bez kontenera w tle i bez sieci
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m54s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m54s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 39s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s
build / build (push) Successful in 56s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m53s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m51s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 39s
Testy / Kontrola składni wszystkich warstw (push) Successful in 20s
Job „Build obrazu silnika B" padal na kazdym przebiegu po pierwszym:

    Conflict. The container name "/swe" is already in use

Dwie wady starego kroku, obie moje:
1. startowal kontener w tle (docker run -d --name swe) i NIGDY go nie usuwal,
   wiec nazwa zostawala zajeta na runnerze i kolejne przebiegi sie wywalaly;
2. pukal curl-em w localhost:8003, podczas gdy job Gitea Actions sam dziala
   w kontenerze, a -p publikuje port na HOSCIE — to nie ten sam localhost,
   wiec health-check i tak nie mial prawa dojsc.

Teraz test biegnie WEWNATRZ obrazu (docker run --rm ... python -), wolajac
funkcje endpointow wprost. Omija oba problemy, nie zostawia niczego po sobie,
a sprawdza to samo i wiecej: obraz sie zbudowal, pyswisseph liczy, komplet 13
obiektow, Slonce w oczekiwanym zakresie, SN = NN + 180.

Dodany krok sprzatajacy osierocony kontener „swe" ze starych przebiegow.

Zweryfikowane lokalnie na realnym pyswisseph: Sun=40.2102, NN=68.1530,
13 obiektow — zgodnie z wyrocznia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:29:27 +02:00
gitea 5203ba9e76 fix(llm): konfiguracja per dostawca — przelacznik w UI byl iluzja
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m49s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 28s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 23s
build / build (push) Successful in 1m14s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m0s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m0s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 50s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
UI pozwala wybrac dostawce przy KAZDYM zadaniu, ale factory czytalo jedna
wspolna trojke LLM_MODEL / LLM_BASE_URL / LLM_API_KEY dla wszystkich. Na
klastrze LLM_BASE_URL trzeba ustawic na lokalny model (localhost:11434 w podzie
nie istnieje) — i wtedy:
  - wybor „OpenAI" wysylal zadanie do Ollamy,
  - LLM_MODEL=llama3.1:8b kazal Anthropic uzyc modelu llama,
  - jeden LLM_API_KEY nie moze byc kluczem OpenAI i Anthropic naraz.
Czyli nie bylo miejsca, w ktore dalo sie sensownie wpisac klucze do chmury.

- konfiguracja per dostawca: <DOSTAWCA>_MODEL / _BASE_URL / _API_KEY
  (LOCAL_*, OPENAI_*, ANTHROPIC_*),
- zgodnosc wstecz: wspolne LLM_* dziala nadal, ale stosuje sie WYLACZNIE do
  dostawcy domyslnego (LLM_PROVIDER) — instalacja jednodostawcowa bez zmian,
- Anthropic dostal brakujaca walidacje klucza (mial ja tylko OpenAI),
- komunikat bledu wskazuje konkretna zmienna do ustawienia.

Testy regresyjne pilnuja, ze ustawienia jednego dostawcy NIE przeciekaja na
pozostalych. Calosc: 143 passed / 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:32:04 +02:00
gitea 64d1afc76d fix(presentation): brakujacy token przy /chart/prompt i /chart/horoscope
build / build (push) Successful in 59s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m52s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 30s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
Blad z mergu: _auth_headers() dodano na galezi hardeningu, ktora odbila sie
od mastera ZANIM powstaly metody prompt() i horoscope() (LOG-29/30, LOG-31).
Git zmergowal obie zmiany czysto — byly w roznych liniach — ale semantycznie
nowe metody wyszly bez tokenu i dostawaly 401 przy wlaczonej ochronie.

Skutek dla uzytkownika: przyciski „Generuj prompt (AI)" i „Napisz horoskop"
nie dzialaly po wdrozeniu INTERNAL_TOKEN, mimo ze reszta aplikacji dzialala.

- naprawione oba wywolania,
- nowy test strukturalny (AST): KAZDE wyjscie HTTP w dol musi niesc headers=.
  Test jednej metody by tego nie zlapal — regula musi byc pilnowana calosciowo.

Straznik zweryfikowany sabotazem: po usunieciu naglowka test pada ze
wskazaniem konkretnej linii; po przywroceniu 15/15 przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:02:40 +00:00
gitea 4da5f5fe7e docs: braki bezpieczenstwa jako wymagania (PRE-16/17, DAN-25/26, LOG-33)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m53s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m56s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 44s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 34s
build / build (push) Successful in 52s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m51s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m56s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 30s
Testy / Kontrola składni wszystkich warstw (push) Successful in 20s
Obecny poziom ochrony wystarcza do developmentu, ale luki musza byc zapisane,
zeby nie wyparowaly przed produkcja.

- PRE-16 (Must) HTTPS/TLS — dzis Basic Auth leci po http, czyli haslo da sie
  podsluchac. Odblokowuje przy okazji DWIE funkcje zepsute z tego samego
  powodu: geolokalizacje (Tu i teraz) i kopiowanie do schowka — oba wymagaja
  secure context.
- PRE-17 (Should) konta imienne + slad audytowy zamiast jednego wspolnego
  hasla; bez tego nie wiadomo, kto pobieral dane, ani jak odciac jedna osobe.
- DAN-25 (Must) ograniczenie udzialu NFS — kto ma do niego dostep, bierze
  komplet baz z pominieciem aplikacji. Dzis najkrotsza droga do wycieku.
- DAN-26 (Should) znakowanie baz rekordami-pulapkami — zabezpieczenie
  detekcyjne: pozwala udowodnic zrodlo wycieku.
- LOG-33 (Should) sekrety w spoczynku (etcd to tylko base64) + rotacja.

Q-12 odnotowane jako rozstrzygniete: bazy zostaly KUPIONE, wiec zgoda jest —
ale to nie zwalnia z ochrony. LOG-32 przestawione na "W trakcie" z wykazem,
co juz wdrozone, a co zostaje.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:47:21 +02:00
gitea 4ef90b30bc feat(logic): dostawcy LLM i pisanie horoskopu (LOG-31)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m38s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 24s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 24s
build / build (push) Successful in 59s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m53s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
Domyslnie model LOKALNY — prompt niesie oryginalne opisy z baz, wiec
domyslnie NIC nie opuszcza sieci. Chmura wlaczana swiadomie (LOG-32).

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:34:07 +02:00
gitea 73fd41b9d3 fix(logic): pomijaj trywialne aspekty par sztywnych (NN/SN)
build / build (push) Successful in 53s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m44s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 26s
Testy / Kontrola składni wszystkich warstw (push) Successful in 23s
Węzły księżycowe są z definicji w dokładnej opozycji (SN = NN + 180°),
więc wpis "North Node opposition South Node orb 0.00°" nie niósł żadnej
informacji astrologicznej — zaśmiecał /chart/positions i UI horoskopu,
a w generatorze promptów LLM zjadał budżet znaków i mógł zostać wzięty
przez model za realne świadectwo.

- aspects.py: RIGID_PAIRS + pomijanie takich par w find_aspects;
  struktura zbioru pozwala dopisać kolejne pary definicyjne,
- aspekty węzłów do pozostałych obiektów bez zmian,
- testy: jawne sprawdzenie braku pary NN/SN (jednostkowo i na
  horoskopie referencyjnym).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:33:16 +00:00
gitea 752f477a27 feat(security): zamkniecie dostepu do baz interpretacyjnych (LOG-32)
build / build (push) Successful in 1m16s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m9s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m51s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 26s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
Bazy sa rdzeniem produktu i wlasnie zostaly kupione — a aplikacja nie miala
ZADNEGO uwierzytelniania. Prezentacja to NodePort, wiec kazdy w LAN wchodzil
bez logowania, a `/search` oddawal surowe wiersze do 50 000 na zapytanie.
Bazy mogly wyjsc przez sama aplikacje, bez udzialu jakiegokolwiek LLM.

- prezentacja: HTTP Basic (APP_USER/APP_PASSWORD) + limit zadan na IP
  (RATE_LIMIT_PER_MIN, domyslnie 120/min). Limit dziala TAKZE przed
  uwierzytelnieniem, zeby zgadywanie hasla i sondowanie API nie bylo darmowe.
- logika i dane: token miedzywarstwowy X-Astrololo-Token (INTERNAL_TOKEN) —
  bez niego dalo sie ominac logowanie, uderzajac wprost w warstwe nizej.
  Warstwa danych oddaje surowe wiersze, wiec to najwrazliwszy punkt.
- /search: gorny limit 50 000 -> 5000 (tyle realnie uzywa build_report).
  Publiczne /api/query zostaje na 200.
- /health celowo publiczny (sondy k8s go nie uwierzytelnia).
- swiadomie nie logujemy tresci zadan ani promptow — logi to kolejny nosnik.

Fail-open przy braku konfiguracji (zgodnosc wstecz i dev), ale z GLOSNYM
ostrzezeniem przy starcie, zeby nikt nie wdrozyl tego w przekonaniu, ze jest
chroniony. Wlaczenie w produkcji wymaga ustawienia sekretow w repo deploy.

Testy: 12 (prezentacja, nowy katalog + job w CI) i 5 (logika). Zweryfikowane
na zywym stosie: bez hasla 401, z haslem 200, logika wprost bez tokenu 401,
z tokenem 200, /health 200, limit 50000 odrzucony (422), a prezentacja nadal
liczy horoskop przez logike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:50:18 +00:00
gitea 04b26afa6d feat(logic): generator promptow do LLM + budzetowanie (LOG-29, LOG-30)
build / build (push) Successful in 1m4s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 26s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
Pierwszy krok ustalonej kolejnosci: prompt z podgladem, uzyteczny od razu
BEZ integracji API (ta przyjdzie w LOG-31).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:44:17 +00:00
gitea 2892b71be3 docs: wymagania feature'u horoskop AI (LOG-29..32, PRE-14/15, Q-12/13)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m42s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 29s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 18s
build / build (push) Successful in 49s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m44s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 26s
Nowy feature: generowanie promptow do ChatGPT/Claude z naszych wyliczen i
wolanie modelu — horoskop urodzeniowy (ekran Interpretacje) oraz horoskop
na wybrany okres (ekran Kalendarz).

Warstwa logiczna:
- LOG-29 generator promptow (profile natal + okresowy), prompt i wynik PL,
  obowiazek odnoszenia kazdej tezy do konkretnego sygnifikatora
- LOG-30 ograniczanie rozmiaru: dedup -> grupowanie -> sortowanie wg wagi
  (LOG-21) -> obciecie ogona -> skracanie; raportuje ile pominieto
- LOG-31 pluggable LLMProvider (OpenAI/Anthropic) + POST /chart/horoscope;
  klucz tylko z sekretu, limity kosztu/tokenow, prompt zwracany zawsze
- LOG-32 (Must) poufnosc: prompt niesie WLASNOSC wspolpracownika i dane
  urodzeniowe -> zgoda wlasciciela baz, API zamiast czatu konsumenckiego,
  minimalizacja, podglad przed wyslaniem, tryb bez pelnych opisow

Warstwa prezentacji:
- PRE-14 przycisk generowania + budzet promptu + podglad promptu przed
  wyslaniem + kopiowanie (prompt dziala takze bez wysylki do API)
- PRE-15 transparentnosc: dostawca/model, ile wskazan pominieto,
  zastrzezenie ze to nie porada medyczna, ostrzezenie przed wysylka

Pytania otwarte: Q-12 (zgoda wlasciciela baz — blokuje tryb pelnych opisow),
Q-13 (dostawca/model i limit kosztow).

Zaktualizowane liczniki w arkuszu Przeglad i zakresy autofiltrow.
Decyzje wg ustalen w czacie: integracja API, pelne opisy z baz, PL, suwak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:46:44 +02:00
gitea 6f87b2b323 feat(logic): systemy zodiaku — syderyczny, draconic (LOG-04)
build / build (push) Successful in 1m6s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m51s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 25s
Testy / Kontrola składni wszystkich warstw (push) Successful in 20s
Jedyne wymaganie Must bez implementacji. Nowy zodiac.py + wpiecie w horoskop:

- zodiac.py: ayanamsy (Lahiri, Fagan-Bradley, Krishnamurti) modelem
  ayan(jd)=ayan0+B*x+C*x^2 (wspolna precesja, rozna stala) — skalibrowanym
  do Swiss Ephemeris jako WYROCZNI: zgodnosc do ~0,02" w latach 1900-2100.
  Draconic = wzgledem wzla wznoszacego (wzel = 0 Barana). RA: konwersja
  ekliptyka->rownik (to_equatorial) na przyszly widok rownikowy.
- chart.py: build_chart(..., zodiac): offset jednolicie przesuwa etykiety
  znakow/dlugosci obiektow, osi, cusps i Lots; DOMY licza sie po dlugosci
  tropikalnej (geometria niezmiennicza wzgledem obrotu -> numery domow bez
  zmian). Domyslnie tropical -> sciezka i wyniki bez zmian.
- main.py: /chart/positions przyjmuje `zodiac`; bledny -> 422.
- prezentacja: dropdown „Zodiak" + pokazanie ayanamshy w wynikach.

Testy (14): ayanamsy vs wyrocznia swisseph (<0.1"), julian_day, draconic
(wzel=0 Barana), niezmienniczosc domow, RA w punktach charakterystycznych,
odrzucenie bledow. Cala logika: 99 passed, 1 skipped. Zweryfikowane e2e w
przegladarce (horoskop syderyczny Lahiri: Slonce Aries 16°34', ayan 23.6382).

RA jako osobny widok zodiaku (per-obiekt, z szerokoscia) — do osobnego PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:32:51 +00:00
gitea a2aabd37a5 feat(presentation): wyszukiwarka lokalizacji + mapa (OSM/Leaflet)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m39s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 28s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s
build / build (push) Successful in 57s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
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:32:07 +02:00
gitea de5d58958c feat(presentation): domyslna lokalizacja = Szpital Barlickiego, Lodz
build / build (push) Successful in 1m9s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m43s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 47s
Testy / Kontrola składni wszystkich warstw (push) Successful in 24s
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 01:16:24 +00:00
gitea ab6fc0722c ci(swisseph): osobny workflow budujacy obraz silnika B
build-swisseph / build (push) Successful in 4m13s
build / build (push) Successful in 1m45s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m52s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 1m13s
Testy / Kontrola składni wszystkich warstw (push) Successful in 39s
Buduje i pushuje obraz gitea/astrololo-engine-swisseph (tag SHA + latest).
Celowo oddzielony od build.yaml (data/logic/presentation) — odpala sie
tylko przy zmianach w services/engine-swisseph/**, wiec obraz AGPL nie
miesza sie do pipeline'u permisywnego produktu. workflow_dispatch pozwala
zbootstrapowac pierwszy obraz recznie.

Obraz konsumuje profil deployu astrololo-swisseph (repo deploy).

Uwaga: pierwszy build zadziala dopiero po zmergowaniu poprawki Dockerfile
(PR #3) — na masterze Dockerfile jeszcze sie nie buduje.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:10:55 +00:00
gitea 6ced2ddd22 fix workflows
build / build (push) Successful in 1m1s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m39s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 6m53s
Testy / Kontrola składni wszystkich warstw (push) Successful in 27s
2026-07-20 18:50:54 +00:00
gitea 79bce8ae90 Lots / punkty arabskie — 7 Lots hermetycznych (LOG-08)
- engine/lots.py: formuła Lot = C + A − B z odwracaniem w horoskopach nocnych
  (zamiana A<->B); 7 Lots hermetycznych (Fortuna, Duch, Eros, Konieczność,
  Odwaga, Zwycięstwo, Nemezis) — Fortuna i Duch liczone pierwsze, bo pozostałe
  się do nich odwołują. Dwa warianty: degree (domyślny) i sign (całe znaki).
- build_chart: liczy sektę (reużyta is_day_birth z Firdarii) i zwraca lots
  ze znakiem, pozycją i domem; parametr lots_method.
- wyszukiwarka: token [PF (tak Fortuna występuje w realnej bazie) + rozwinięcie
  skrótu do "Part of Fortune".
- widok Horoskop: tabela Lots z formułami i sektą.

Walidacja (dwie niezależne wyrocznie z notes3):
- Fortuna = Can 12°35'27" vs astro-seek Can 12°35'24" (3 sekundy różnicy), dom 1;
- Duch potwierdzony przez swoją antyscję (Taurus 28°14' z tabeli antyscji);
- odwracanie nocne: Fortuna nocna == Duch dzienny i odwrotnie;
- Lots pochodne faktycznie używają Fortuny/Ducha; wariant sign trafia w 0° znaku.
85 testów przechodzi (nowy test_lots).

Odblokowuje Zodiacal Releasing (LOG-11), które startuje z Fortuny/Ducha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:50:54 +00:00
gitea 85e9182f12 fix(swisseph): naprawa builda obrazu silnika B
build / build (push) Successful in 43s
pyswisseph to rozszerzenie C, a na PyPI (2.10.3.2) gotowe wheels koncza sie
na cp311 i obejmuja wylacznie i686/x86_64. Obraz stoi na python:3.12-slim,
wiec pip ZAWSZE kompilowal ze zrodel - a slim nie ma kompilatora. Stad fail.

- Dockerfile wieloetapowy: kompilacja w etapie builder (build-essential),
  do runtime trafia juz tylko gotowy wheel - obraz zostaje czysty i maly.
  Dziala tez na arm64, gdzie wheeli linuksowych nie ma dla zadnej wersji.
- sanity check (import swisseph) na etapie builda, zeby niedzialajacy silnik
  wywracal build, a nie dopiero pierwszy request.
- CI: nowy job swisseph-image - realny docker build + smoke test /health
  i /positions na horoskopie referencyjnym.
- README: udokumentowany powod wieloetapowego builda.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:30:10 +02:00
gitea c75f8377bf kick image updater
build / build (push) Successful in 49s
2026-07-20 19:52:21 +02:00
gitea 5234e86c95 ttt
build / build (push) Successful in 1m18s
2026-07-20 14:50:11 +02:00
gitea 0ac8df250c test image updatera
build / build (push) Successful in 51s
2026-07-19 23:20:39 +02:00
gitea 9323803cb4 tt
build / build (push) Successful in 5m31s
2026-07-18 21:57:33 +02:00
gitea 678c3bad76 test 2 2026-07-18 18:11:42 +02:00
gitea 88a1ecd3c4 test akcji 2026-07-18 18:04:16 +02:00
gitea b28c612bf7 CI: jawne pobranie jądra efemeryd + brak silnika = błąd zamiast pomijania
Pierwszy run CI dał 59 passed / 20 skipped zamiast 78/1: auto-pobranie jądra
przez Skyfield nie powiodło się na runnerze, więc testy referencyjne (walidacja
względem astro.com) cicho znikały.

- workflow: jawne pobranie de421.bsp curlem z ssd.jpl.nasa.gov (naif zwraca 404),
  z retry; EPHEMERIS_DIR wskazany explicite; pytest z -rs (widoczne powody skipów).
- conftest: gdy CI=true, niedostępny silnik kończy się pytest.fail zamiast skip —
  żeby utrata pokrycia nigdy więcej nie przeszła niezauważona. Lokalnie (dev bez
  pobranego jądra) nadal łagodny skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:47:50 +02:00
gitea 2bda4e311d CI: uruchamianie testów przy każdym pushu (GitHub Actions)
.github/workflows/tests.yml:
- job "Testy warstwy logicznej": Python 3.12 (jak w obrazach Dockera),
  instalacja requirements-dev, pytest na services/logic/tests. Cache pip oraz
  cache jądra efemeryd JPL (de421.bsp ~17 MB), by nie pobierać go co run.
- job "Kontrola składni": compileall na wszystkich warstwach (bez instalowania
  zależności, w tym AGPL-owego silnika swisseph).
- Triggery: każdy push (dowolna gałąź) + pull requesty do master.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:02:37 +02:00
gitea 5d0d86cb90 Merge pull request #22 from migatu/feat/firdaria-log11
Firdaria (LOG-11) — perska technika time-lord
2026-07-17 22:50:39 +02:00
gitea 9d5b65ddbd Firdaria (LOG-11) — perska technika time-lord
- engine/firdaria.py: sekta (dzień = Słońce nad horyzontem, ta sama półkula
  osi Asc-Dsc co MC); kolejność diurnalna/nokturnalna; klasyczne długości
  okresów (Su10 Ve8 Me13 Mo9 Sa11 Ju12 Ma7 + NN3 + SN2 = 75 lat); okresy główne
  planet dzielone na 7 podokresów (sub-lord od władcy okresu), węzły bez sub.
- endpoint /chart/firdaria.
- oś czasu: firdaria_events — starty (pod)okresów w oknie; wpięte w build_timeline
  (domyślnie) + tokeny [major][sub] do dopięcia interpretacji (1B->2B).

Walidacja:
- sekta = day dla horoskopu referencyjnego (zgodnie z notes3 "Day birth");
  night gdy Słońce po stronie IC; sumy i przyleganie okresów; podokresy 7x
  sumujące się do okresu; wiek 42 w okresie Saturna.
- E2E: okresy Sun 1984-1994 ... Saturn 2024-2035; w osi czasu
  "Firdaria: Saturn / Mars" 2027 -> 223 interpretacje ([Sa+[Ma -> "injury").
- 78 testów przechodzi (nowy test_firdaria).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:48:53 +02:00
gitea cff0ac194c Merge pull request #21 from migatu/feat/timeline-interpretations
build
2026-07-17 22:39:58 +02:00
gitea 73f39e7df8 build 2026-07-17 19:30:14 +02:00
gitea 96d983b26a Merge pull request #20 from migatu/feat/timeline-interpretations
Spiecie osi czasu z baza interpretacji i widokiem (LOG-14, 1B->2B)
2026-07-11 12:33:46 +02:00
gitea c4a181b810 Spięcie osi czasu z bazą interpretacji i widokiem (LOG-14, 1B->2B)
Realizuje przepływ 1B->2B z notes2: predykcyjne sygnifikatory (z datami)
dopasowane do interpretacji z bazy.

Logika:
- timeline.py: zdarzenia niosą strukturę (directed/aspect/target dla dyrekcji,
  lord/sign dla profekcji) do budowy tokenów.
- significators.interpret_events + _event_tokens: z każdego zdarzenia buduje
  tokeny bazy (dyrekcja: [planeta][aspekt][cel]; profekcja: [władca][znak]) i
  dopina interpretacje reużywając _facet_samples (AND tokenów, dedup, rozwinięcie).
- /chart/timeline: flaga interpret=true.

Prezentacja:
- nowa strona /timeline "Kalendarz": formularz (urodzenie + zakres dat) -> oś
  czasu z technikami, datami i interpretacjami; nawigacja + wspólne now.js.

Walidacja E2E na realnym main_base.xlsx (2025-2026):
- dyr. Saturn kwadratura MC -> 50 interpret. ("...5th house" -> "abortion/miscarriage")
- Władca Roku Saturn (wiek 42) -> 63; strona renderuje badge dat/technik.
71 testów przechodzi (nowy test_timeline_interpret).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:31:50 +02:00
gitea 97ad21d2e4 Merge pull request #19 from migatu/feat/timeline-log14
Zbiorcza tabela dat z technik (LOG-14)
2026-07-10 23:53:10 +02:00
gitea 2dbd3410e9 Zbiorcza tabela dat z technik (LOG-14)
engine/timeline.py: scala w jedną posortowaną oś czasu (technique | significator |
start | exact | end):
- profekcje roczne (LOG-10) — Władca Roku per rok życia,
- Solar Return (LOG-12) — moment powrotu Słońca,
- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów
  natalnych (klucz Naiboda 0°59'08"/rok, konfigurowalny; okno orbowe ±1 rok).
Endpoint /chart/timeline (zakres from_date..to_date, wybór technik).

Walidacja:
- profekcje spójne z tabelą notes3; dyrekcja Sun koniunkcja MC (łuk 312°) słusznie
  poza życiem; oś posortowana po dacie dokładnej; wiek = łuk/klucz spójny z datą.
- E2E: dla 2025-2026 zwraca 19 zdarzeń (m.in. Władca Roku 42 Saturn 30.04.2026,
  Solar Return, dyr. Venus koniunkcja North Node 27.05.2025). 66 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:33:15 +02:00
gitea ca458fd741 Merge pull request #18 from migatu/feat/profections-returns
Profekcje roczne (LOG-10) i Solar/Lunar Return (LOG-12)
2026-07-10 13:40:51 +02:00
gitea b013831492 Profekcje roczne (LOG-10) i Solar/Lunar Return (LOG-12)
Pierwsze techniki predykcyjne.

Profekcje (LOG-10):
- engine/profections.py: Whole Sign, wiek mod 12; profektowany Asc + Władca Roku
  (władca domicylowy) + profekcje MC/Su/Mo. Obsługa 29 lutego.
- endpoint /chart/profections (zakres lat: start_age, count).

Returns (LOG-12):
- engine/returns.py: moment powrotu Słońca/Księżyca do długości natalnej;
  skan dobowy + bisekcja do ~sekundy, z pominięciem artefaktu zawinięcia 0/360.
- endpoint /chart/return (kind solar|lunar, around) — zwraca pełny horoskop
  na znaleziony moment (oba warianty użycia po stronie technik wyżej).

Walidacja:
- profekcje zgodne co do joty z tabelą astro-seek z notes3 (wiek 0..42:
  Asc + Władca Roku + MC/Su/Mo).
- Solar Return 2026: Słońce wraca do Tau 10°08'22" = natalny stopień;
  Lunar Return trafia natalny Księżyc <2'; samospójność potwierdzona.
- 62 testy przechodzą (nowe: test_profections, test_returns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:12:49 +02:00
gitea d14a77360a Merge pull request #17 from migatu/feat/nodes-lilith-stations
Wezly ksiezycowe, mean Lilith i wykrywanie stacji (LOG-02/03)
2026-07-08 10:53:43 +02:00
gitea a8c3072e62 Węzły księżycowe, mean Lilith i wykrywanie stacji (LOG-02/03)
Punkty wirtualne (LOG-02):
- engine/points.py: mean Node (Ω) i mean Lilith (apogeum) wzorami Meeusa;
  prędkości numerycznie. SN = NN + 180° (ta sama prędkość), zawsze Rx.
- DEFAULT_OBJECTS + North Node / South Node / Lilith — automatycznie dostają
  domy, aspekty i A/S. Parzystość silnika B: swe.MEAN_NODE / swe.MEAN_APOG
  (uwaga: stała pyswisseph to MEAN_APOG, nie MEAN_APOGEE).
- significators: tokeny [NN / [SN / [Lilith (zgodne z SIGNIFICATORS KEY).

Stacje (LOG-03):
- engine/stations.py: skan prędkości (krok 4 dni, okno ±800 dni — pokrywa
  najdłuższe przerwy Marsa/Wenus) + bisekcja; klasyfikacja SD/SR; poprzednia/
  następna stacja (dni, data, stopień w znaku) + flaga station_soon (<7 dni).
- /chart/positions: opt-in stations:true; UI: checkbox + tabela stacji.

Walidacja:
- mean NN vs astro-seek (Gem 8°09'24"): Δ=0,3'; vs swisseph: Δ=17";
  mean Lilith vs swisseph: Δ=1,5'. NN dom 12 / SN dom 6 zgodnie z astro-seek.
- Stacje Marsa 1984 trafiają w historię: SR 5.04.1984, SD 19.06.1984;
  samospójność |speed|<0,01°/d w znalezionych momentach; flaga "blisko"
  działa (Merkury +5,3d, Jowisz -0,6d).
- E2E na realnej bazie: [SN 134 rekordy, trafienie w 6. domu. 54 testy przechodzą.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:51:29 +02:00
gitea 93932246f3 Merge pull request #16 from migatu/feat/aspects-applying
Aspekty: applying/separating (A/S) + bonus sily dla aplikujacych (LOG-06)
2026-07-07 21:13:02 +02:00
gitea 8d579de34a Aspekty: applying/separating (A/S) + bonus siły dla aplikujących (LOG-06)
- aspects.py: _is_applying — odchyłka od dokładnego kąta teraz vs po małym
  kroku czasu (prędkość·dt, dt=0,01 doby by szybki Księżyc nie przeskoczył
  dokładności); aspekt niesie applying (bool) i "as": A/S. Bez prędkości —
  brak flagi (None).
- scoring: aspekt aplikacyjny silniejszy (APPLYING_BONUS 1.15) — zgodnie z
  notatkami projektu ("impact considered more powerful").
- significators: faseta aspektu niesie applying, etykieta z sufiksem (A)/(S).
- widok Horoskop: kolumna A/S w tabeli aspektów (tooltip z objaśnieniem).

Walidacja: flagi A/S wszystkich 16 aspektów horoskopu referencyjnego
(30.04.1984, Warszawa) zgodne z tabelą astro-seek z notes3 (test regresyjny
REFERENCE_AS). 45 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:22:51 +02:00
gitea db3d1e5117 Merge pull request #15 from migatu/fix/geo-secure-context
Geolokalizacja: jawny komunikat gdy brak secure context (http)
2026-07-07 19:01:19 +02:00
82 changed files with 8053 additions and 53 deletions
+32
View File
@@ -0,0 +1,32 @@
name: build-swisseph
# Osobny pipeline dla silnika B (Swiss Ephemeris, AGPL) — celowo ODDZIELONY od
# głównego build.yaml (data/logic/presentation). Buduje się tylko, gdy zmienia się
# sam silnik, i nie miesza obrazu AGPL do pipeline'u permisywnego produktu.
#
# Obraz konsumuje profil deployu `astrololo-swisseph` w repo `deploy`.
on:
push:
branches: [master]
paths:
- 'services/engine-swisseph/**'
- '.gitea/workflows/build-swisseph.yaml'
workflow_dispatch: {} # ręczne odpalenie (bootstrap pierwszego obrazu)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push engine-swisseph (AGPL, izolowany)
run: |
TAG=${GITHUB_SHA::8}
IMG=gitea.czernobog.pl/gitea/astrololo-engine-swisseph
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312) — build jest
# zarazem realnym testem Dockerfile'a.
docker build -t $IMG:$TAG -t $IMG:latest ./services/engine-swisseph
docker push $IMG:$TAG
docker push $IMG:latest
echo "Zbudowano i wypchnięto: $IMG:$TAG (+ latest)"
+19
View File
@@ -0,0 +1,19 @@
name: build
on:
push:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push (data, logic, presentation)
run: |
TAG=${GITHUB_SHA::8}
for SVC in data logic presentation; do
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
done
echo "Tag: $TAG"
+142
View File
@@ -0,0 +1,142 @@
name: Testy
# Odpala się przy każdym pushu (dowolna gałąź) oraz dla pull requestów do master.
on:
push:
pull_request:
branches: [master]
jobs:
logic-tests:
name: Testy warstwy logicznej (silnik)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python 3.12 (jak w obrazach Dockera)
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/logic/requirements-dev.txt
# Jądro efemeryd JPL (de421.bsp, ~17 MB). Cache'ujemy je między runami.
- name: Cache jądra efemeryd
uses: actions/cache@v4
with:
path: services/logic/.ephemeris
key: ephemeris-de421
# Pobieramy jawnie (a nie licząc na auto-pobranie przez Skyfield), żeby
# brak jądra był twardym błędem, a nie cichym pomijaniem testów.
- name: Pobierz jądro efemeryd (gdy brak w cache)
run: |
mkdir -p services/logic/.ephemeris
if [ ! -s services/logic/.ephemeris/de421.bsp ]; then
curl -fSL --retry 3 --max-time 300 \
-o services/logic/.ephemeris/de421.bsp \
https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp
fi
ls -lh services/logic/.ephemeris/de421.bsp
- name: Instalacja zależności
run: pip install -r services/logic/requirements-dev.txt
# CI=true (ustawiane przez GitHub) sprawia, że brak silnika = błąd,
# a nie pominięcie — patrz tests/conftest.py.
- name: Testy (pytest)
working-directory: services/logic
env:
PYTHONPATH: .
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
run: pytest tests -q -rs
presentation-tests:
name: Testy warstwy prezentacji (dostęp do baz)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/presentation/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/presentation/requirements-dev.txt
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
# niż jego brak, bo daje złudzenie zabezpieczenia.
- name: Testy (pytest)
working-directory: services/presentation
env:
PYTHONPATH: .
run: pytest tests -q -rs
swisseph-image:
name: Build obrazu silnika B (swisseph)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312), więc ten
# build jest realnym testem Dockerfile'a — nie tylko pobraniem paczek.
- name: docker build
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
# Test biegnie WEWNĄTRZ obrazu, bez sieci i bez kontenera w tle. Poprzednia
# wersja startowała kontener w tle (--name swe) i pukała curl-em w
# localhost:8003 — co miało dwie wady:
# 1. nie sprzątała kontenera, więc każdy kolejny przebieg padał na
# konflikcie nazwy (Conflict. The container name "/swe" is already in use),
# 2. job Gitea Actions sam działa w kontenerze, a -p publikuje port na
# HOŚCIE — więc localhost joba to nie ten sam localhost.
# Wywołanie funkcji endpointów wprost omija oba problemy, a sprawdza to samo:
# obraz się zbudował, pyswisseph liczy, kontrakt /positions się zgadza.
# --rm gwarantuje, że nic nie zostaje po przebiegu.
- name: Smoke test (health + pozycje) wewnątrz obrazu
run: |
docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
from datetime import datetime, timezone
from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
h = health()
assert h["status"] == "ok", h
print("health:", h)
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
lat=50.0647, lon=19.9450)
out = positions(req)
by = {p["name"]: p for p in out["positions"]}
assert out["engine"] == "swisseph", out["engine"]
assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
sun = by["Sun"]["longitude"]
assert 39.5 < sun < 41.0, f"Slonce poza oczekiwanym zakresem: {sun}"
nn, sn = by["North Node"]["longitude"], by["South Node"]["longitude"]
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
print("SMOKE OK")
PY
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
- name: Usuń osierocony kontener ze starych przebiegów
if: always()
run: docker rm -f swe 2>/dev/null || true
compile-all:
name: Kontrola składni wszystkich warstw
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# Sam kompilator — bez instalowania zależności warstw (w tym AGPL-owego
# silnika swisseph, który nie wchodzi do produktu).
- name: py_compile
run: python -m compileall -q services
+1
View File
@@ -73,3 +73,4 @@ export DATA_PROVIDER=sql # przełącz całą warstwę
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
# updater test Mon 20 Jul 2026 19:52:08 CEST
Binary file not shown.
+296
View File
@@ -0,0 +1,296 @@
# Wdrożenie PRE-16 — HTTPS na wejściu i szyfrowanie łączy między warstwami
Instrukcja krok po kroku. **Kolejność ma znaczenie** — punkt „Dlaczego taka
kolejność" niżej tłumaczy, co się stanie, jeśli ją zamienić.
Dotyczy dwóch pull requestów:
| Repo | PR | Co wnosi |
|---|---|---|
| `gitea/astrololo` | [#21](https://gitea.czernobog.pl/gitea/astrololo/pulls/21) | kod: szyfrowanie łączy, limit żądań za proxy |
| `gitea/deploy` | [#4](https://gitea.czernobog.pl/gitea/deploy/pulls/4) | manifesty: Ingress, certyfikat, klucze łączy |
---
## Co się właściwie zmienia
**Na wejściu do aplikacji.** Dotąd logowanie szło przez HTTP Basic po zwykłym
http — czyli hasło leciało siecią w postaci trywialnej do podsłuchania (base64 to
nie szyfrowanie). Po zmianie wejście jest po https, a http odsyła na https.
Przy okazji **odblokowują się dwie funkcje zepsute dziś z tego samego powodu**:
geolokalizacja („Tu i teraz") i kopiowanie promptu do schowka działają wyłącznie
w tzw. secure context i po http po prostu odmawiały.
**Między warstwami.** Prezentacja, logika i dane rozmawiały ze sobą otwartym
tekstem wewnątrz klastra. Token międzywarstwowy mówił *kto* pyta, ale nie ukrywał
*czego dotyczy odpowiedź* — a płyną nią surowe wiersze oryginalnych baz. Teraz
każde ciało żądania i odpowiedzi jest szyfrowane **AES-256-GCM**, osobnym kluczem
na każdą parę rozmówców.
**Wejście na świat pozostaje jedno: prompt do modelu.** Ta zmiana niczego tu nie
rusza — dotyczy wyłącznie ruchu wewnątrz sieci i wejścia z przeglądarki.
---
## Zanim zaczniesz — stan wyjściowy
```bash
kubectl -n astrololo get deploy,svc
kubectl -n astrololo get secret # powinny być: astrololo-auth, gitea-registry
kubectl -n kube-system get svc traefik -o jsonpath='{.status.loadBalancer.ingress[*].ip}'; echo
```
Zanotuj adres Traefika — będzie potrzebny w kroku 3. Sprawdź też, czy działa
aplikacja w obecnej postaci (przez NodePort), żeby mieć punkt odniesienia.
---
## Krok 1 — sekret z kluczami łączy
**Przed czymkolwiek innym.** Klucze muszą istnieć, zanim pody spróbują wstać
z nową konfiguracją, bo bez nich celowo **nie wystartują**.
```bash
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)"
```
Kluczy nikt nigdy nie musi oglądać — służą tylko usługom. Nie ma ich w repo
GitOps i **nie ma ich tam wkładać**: cokolwiek trafi do gita, zostaje w historii
na zawsze.
Dwa osobne klucze to nie ozdobnik. Przejęcie klucza prezentacji nie daje dostępu
do warstwy danych, gdzie leżą całe bazy. Logika dostaje oba, bo rozmawia w obie
strony; prezentacja i dane dostają wyłącznie swój.
Sprawdź:
```bash
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
# oczekiwane: dwa klucze, każdy 64 znaki po odkodowaniu (32 bajty)
```
---
## Krok 2 — cert-manager
Jednorazowo, na cały klaster:
```bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook --timeout=180s
```
Poczekaj, aż **webhook** będzie gotowy — dopóki nie wstanie, tworzenie obiektów
`Certificate` kończy się błędem połączenia i wygląda jak zepsuty manifest.
Sprawdź:
```bash
kubectl get crd | grep cert-manager | head -3 # muszą się pojawić
```
> **Dlaczego własne CA, a nie Let's Encrypt.** Klaster stoi w LAN (Traefik trzyma
> LoadBalancera na adresach 192.168.1.x), więc walidacja HTTP-01 nie ma jak dojść
> z internetu, a DNS-01 wymagałby trzymania w klastrze tokena API do domeny.
> Własne CA nie potrzebuje niczego z zewnątrz i odnawia certyfikaty samo. Cena:
> raz na urządzenie importujesz korzeń (krok 6).
---
## Krok 3 — DNS
Wpis `astrololo.czernobog.pl` → adres Traefika z kroku „stan wyjściowy”.
W routerze, lokalnym DNS-ie albo doraźnie w `/etc/hosts`:
```bash
echo "192.168.1.73 astrololo.czernobog.pl" | sudo tee -a /etc/hosts
```
**To nie jest krok opcjonalny.** Service `presentation` przestaje być NodePortem
(był drugą, nieszyfrowaną drogą do aplikacji — czyli obejściem całego PRE-16),
więc po wdrożeniu manifestów nazwa jest jedynym wejściem. Awaryjnie zawsze zostaje:
```bash
kubectl -n astrololo port-forward svc/presentation 8000:8000 # http://localhost:8000
```
---
## Krok 4 — merge PR-a aplikacji (astrololo #21)
Teraz, **przed** manifestami.
```bash
tea pr merge --login gitea --repo gitea/astrololo 21
```
Po merge'u CI zbuduje obrazy, a image-updater sam podbije tagi w repo `deploy`,
skąd ArgoCD wymieni pody. Poczekaj, aż to się przetoczy:
```bash
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
kubectl -n astrololo get pods -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
```
Na tym etapie **nic się jeszcze nie szyfruje** — nowy kod to potrafi, ale zmienne
z kluczami dokłada dopiero PR do `deploy`. Aplikacja działa dokładnie jak dotąd.
To celowe: chcemy, żeby *cała* obsada podów umiała szyfrować, zanim ktokolwiek
tego zażąda.
---
## Krok 5 — merge PR-a manifestów (deploy #4)
```bash
tea pr merge --login gitea --repo gitea/deploy 4
```
ArgoCD zsynchronizuje się sam (`automated`, `selfHeal`). Wjeżdża naraz: Ingress,
certyfikat, zmienne z kluczami, `TRUST_PROXY` i zdjęcie NodePortu.
```bash
kubectl -n argocd get application astrololo
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
kubectl -n astrololo get certificate # astrololo-ca i astrololo-tls: READY=True
```
> **Spodziewaj się kilkudziesięciu sekund błędów w trakcie.** Pody wymieniają się
> po kolei, więc przez chwilę stara prezentacja (jeszcze bez klucza) rozmawia
> z nową logiką (już z kluczem) i dostaje odmowę. To zamierzone: alternatywą byłby
> tryb „przyjmuj i szyfrowane, i jawne”, który zwykle zostaje włączony na zawsze.
Merge nie cofnie tagów obrazów — PR dotyka w `kustomization.yaml` wyłącznie listy
`resources`, nie bloku `images`, więc git złoży to z nowszymi tagami z mastera.
---
## Krok 6 — zaufanie do własnego CA (raz na urządzenie)
Bez tego przeglądarka pokaże ostrzeżenie o certyfikacie. Korzeń jest ważny 10 lat,
więc robisz to raz:
```bash
kubectl -n astrololo get secret astrololo-ca -o jsonpath='{.data.ca\.crt}' \
| base64 -d > astrololo-ca.crt
# macOS — do systemowego zaufania (poprosi o hasło administratora)
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain astrololo-ca.crt
# Linux (Debian/Ubuntu)
sudo cp astrololo-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
```
Firefox ma **własny** magazyn certyfikatów — import przez *Ustawienia →
Prywatność i bezpieczeństwo → Wyświetl certyfikaty → Organy certyfikacji*.
---
## Krok 7 — sprawdzenie, że działa to, co miało zadziałać
### Wejście po https
```bash
curl -sI http://astrololo.czernobog.pl/ | head -2 # 301 → https
curl -s -o /dev/null -w "bez hasła: %{http_code}\n" https://astrololo.czernobog.pl/
curl -s -o /dev/null -w "z hasłem: %{http_code}\n" -u astrololo:'<hasło>' https://astrololo.czernobog.pl/
curl -sI -u astrololo:'<hasło>' https://astrololo.czernobog.pl/ | grep -i strict-transport
```
Oczekiwane: **301**, **401**, **200**, nagłówek HSTS obecny. Brak ostrzeżenia
o certyfikacie w przeglądarce oznacza, że krok 6 się udał.
### W przeglądarce
Kliknij **„Tu i teraz"** — powinno pobrać lokalizację (po http odmawiało).
Wygeneruj prompt i kliknij **kopiuj** — schowek powinien zadziałać bez obejść.
### Szyfrowanie łączy — sprawdzenie wprost
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **bez klucza**:
```bash
kubectl -n astrololo exec deploy/presentation -- \
python -c "
import httpx, os
r = httpx.post('http://logic:8001/chart/report',
json={'when_utc':'1984-04-30T09:20:00+00:00','lat':50.06,'lon':19.94},
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
print(r.status_code, r.text[:120])
"
```
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Zwróć uwagę, że
żądanie miało **prawidłowy token** — sam token już nie wystarcza, i o to chodziło.
To samo w dół, do warstwy danych:
```bash
kubectl -n astrololo exec deploy/logic -- \
python -c "
import httpx, os
r = httpx.post('http://data:8002/search',
json={'key':'significator','value':'[Sat','exact':False,'limit':5},
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
print(r.status_code, r.text[:120])
"
```
### Logi startowe
```bash
kubectl -n astrololo logs deploy/logic | grep -i "łącze\|UWAGA"
```
Powinno być `łącze szyfrowane (AES-256-GCM…)`. Jeśli widzisz ostrzeżenie
o rozmowie **jawnym tekstem** — klucz nie doszedł do poda.
---
## Dlaczego taka kolejność
| Kolejność | Skutek zamiany |
|---|---|
| Sekret **przed** manifestami | `LINK_ENCRYPTION_REQUIRED=true` bez klucza celowo wywraca start. Pody wpadną w CrashLoop i będą tak siedzieć do czasu utworzenia sekretu. |
| cert-manager **przed** manifestami | API odrzuci `Certificate`/`Issuer` jako nieznane rodzaje zasobów, ArgoCD pokaże aplikację jako niezsynchronizowaną i sam tego nie naprawi. |
| DNS **przed** manifestami | NodePort znika razem z nimi. Bez wpisu DNS zostaje tylko `port-forward`. |
| Aplikacja **przed** manifestami | Odwrotnie: manifesty włączyłyby szyfrowanie na obrazach, które go nie znają — wszystkie żądania kończyłyby się odmową do czasu przebudowy obrazów. |
Fail-closed w obie strony jest zamierzony. Usługa, która wstała i **po cichu nie
szyfruje**, jest gorsza niż pod w CrashLoop — awarii nie widać, a bazy jadą
otwartym tekstem.
---
## Wycofanie
Manifestów: `git revert` merge'a w `deploy` — ArgoCD samo wróci do NodePortu
i ruchu bez szyfrowania. Kod aplikacji **nie wymaga wycofania**: bez zmiennych
`LINK_KEY_*` moduł przepuszcza ruch jak dotąd (i głośno o tym mówi w logach).
Certyfikat i CA zostają w namespace; usunięcie: `kubectl -n astrololo delete
certificate astrololo-ca astrololo-tls`. cert-managera można zostawić — nie
przeszkadza.
---
## Gdy coś nie gra
| Objaw | Przyczyna | Co zrobić |
|---|---|---|
| Pody w `CrashLoopBackOff`, w logach `LINK_ENCRYPTION_REQUIRED … nie ustawiony` | brak sekretu `astrololo-link` | krok 1, potem `rollout restart` |
| `400 Łącze międzywarstwowe wymaga szyfrowania` przy normalnym korzystaniu | jedna warstwa ma klucz, druga nie (albo trwa rollout) | `rollout status`; sprawdź, czy wszystkie trzy pody mają zmienną |
| `400 Nie udało się odczytać zaszyfrowanego żądania` | klucze po obu stronach łącza są **różne** | wymień sekret i zrestartuj **wszystkie trzy** naraz |
| `Certificate` stoi w `READY=False` | webhook cert-managera jeszcze nie wstał | `kubectl -n cert-manager get pods`, poczekaj i sprawdź `kubectl -n astrololo describe certificate astrololo-tls` |
| Przeglądarka: „połączenie nie jest prywatne” | korzeń CA nieimportowany na tym urządzeniu | krok 6 (pamiętaj, że Firefox ma osobny magazyn) |
| `404` z Traefika pod adresem aplikacji | DNS wskazuje gdzie indziej niż LoadBalancer Traefika | porównaj `dig +short astrololo.czernobog.pl` z adresem z kroku „stan wyjściowy” |
| Limit żądań odcina wszystkich naraz | brak `TRUST_PROXY=true` — cały ruch liczony jako jeden klient | sprawdź zmienną w `deploy/presentation` |
---
## Czego to nie załatwia
- **Szyfrowane są ciała żądań, nie nagłówki.** Ścieżka (`/search`) i token
międzywarstwowy jadą czytelnie. Sam token nikomu nic nie daje — bez klucza łącza
każde żądanie kończy się odmową — ale metadanych to nie ukrywa. Pełne ukrycie
wymagałoby mTLS.
- **Własne CA to nie publiczne zaufanie.** Każde nowe urządzenie wymaga importu
korzenia. Gdyby aplikacja miała kiedyś wyjść na świat, właściwą drogą jest
Let's Encrypt przez DNS-01.
- **NFS z plikami baz** stoi obok aplikacji — kto ma dostęp do share'u, bierze
pliki z pominięciem wszystkich powyższych zabezpieczeń. Do zamknięcia po stronie
infrastruktury (eksport tylko dla IP węzłów, `root_squash`, najlepiej read-only).
- **Sekrety w etcd** są tylko zakodowane base64. Docelowo: szyfrowanie etcd
at-rest albo Sealed Secrets / SOPS.
+466
View File
@@ -0,0 +1,466 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
try:
return self._by_direction[direction].decrypt(
nonce, sealed, self._aad(direction, path, stamp, seq))
except InvalidTag as exc:
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
# ------------------------------------------------------------ strumień ramek
def seal_stream(self, direction: bytes, path: str, stamp: str,
chunks: Iterable[bytes]) -> Iterator[bytes]:
for seq, chunk in enumerate(chunks):
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
def open_stream(self, direction: bytes, path: str, stamp: str,
raw: bytes) -> Iterator[bytes]:
for seq, frame in enumerate(frames_in(raw)):
yield self.open(direction, path, stamp, seq, frame)
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
return b"".join(self.open_stream(direction, path, stamp, raw))
# ---------------------------------------------------------------- ramkowanie
def frame_out(payload: bytes) -> bytes:
return struct.pack(">I", len(payload)) + payload
def frames_in(raw: bytes) -> Iterator[bytes]:
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
offset = 0
while offset < len(raw):
if offset + LENGTH_PREFIX > len(raw):
raise LinkError("urwana ramka (brak nagłówka długości)")
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
offset += LENGTH_PREFIX
if offset + size > len(raw):
raise LinkError("urwana ramka (za mało danych)")
yield raw[offset:offset + size]
offset += size
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
rozjechać się między dwa odczyty.
"""
while True:
if len(buffer) < LENGTH_PREFIX:
return
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
if len(buffer) < LENGTH_PREFIX + size:
return
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
del buffer[:LENGTH_PREFIX + size]
yield frame
# ------------------------------------------------------------- świeżość ruchu
def stamp_now() -> str:
return f"{time.time():.3f}"
def check_stamp(stamp: str) -> None:
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
odtworzyć w dowolnym momencie w przyszłości."""
try:
sent = float(stamp)
except (TypeError, ValueError) as exc:
raise LinkError("brak albo błędny znacznik czasu") from exc
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
# =========================================================== strona serwerowa
class LinkCryptoMiddleware:
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
"""
def __init__(self, app, link: Link | None, layer: str) -> None:
self.app = app
self.link = link
self.layer = layer
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
return await self.app(scope, receive, send)
path = scope["path"]
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
if headers.get(HEADER_ENC.lower()) != VERSION:
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
# chcemy po cichu wrócić do jawnego ruchu.
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
self.layer, path)
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
stamp = headers.get(HEADER_TS.lower(), "")
try:
check_stamp(stamp)
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
except LinkError as exc:
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
scope = dict(scope)
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
def _sealing_send(self, send, path: str):
state: dict = {"stamp": "", "seq": 0}
async def sealing(message):
if message["type"] == "http.response.start":
state["stamp"] = stamp_now()
keep = [(k, v) for k, v in message.get("headers", [])
if k.lower() not in (b"content-length", b"content-type")]
message = dict(message)
message["headers"] = keep + [
(b"content-type", CONTENT_TYPE.encode()),
(HEADER_ENC.lower().encode(), VERSION.encode()),
(HEADER_TS.lower().encode(), state["stamp"].encode()),
]
return await send(message)
if message["type"] == "http.response.body":
chunk = message.get("body", b"")
sealed = b""
if chunk:
sealed = frame_out(self.link.seal(
RESPONSE, path, state["stamp"], state["seq"], chunk))
state["seq"] += 1
return await send({"type": "http.response.body", "body": sealed,
"more_body": message.get("more_body", False)})
return await send(message)
return sealing
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
kept.append((b"content-length", str(length).encode()))
if length:
kept.append((b"content-type", b"application/json"))
return kept
async def _read_body(receive) -> bytes:
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
raise LinkError("rozłączenie w trakcie odbioru żądania")
body += message.get("body", b"")
if not message.get("more_body", False):
return bytes(body)
def _replay(body: bytes, original):
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
"""
delivered = False
async def receive():
nonlocal delivered
if delivered:
return await original()
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return receive
async def _refuse(send, detail: str) -> None:
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
await send({"type": "http.response.start", "status": 400, "headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
]})
await send({"type": "http.response.body", "body": payload})
def install(app, env_name: str, layer: str):
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
link_key = key_from_env(env_name)
if link_key is None and encryption_required():
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
f"warstwa {layer} nie wystartuje bez klucza łącza"
)
if link_key is None:
log.warning(
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
"ruch wewnątrz sieci.", env_name, layer,
)
return None
link = Link(link_key)
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
return link
# ============================================================ strona kliencka
def call(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
się niejasnym błędem odszyfrowania.
"""
import json as _json
import httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
# sygnifikatory, o które pytamy bazę.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
)
response = client.request(method, url, json=payload, headers=request_headers)
response.raise_for_status()
return response.content
path = httpx.URL(url).path
stamp = stamp_now()
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
"Content-Type": CONTENT_TYPE})
response = client.request(method, url, content=body, headers=request_headers)
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
log.error("łącze %s odmówiło: %s", path, response.text[:200])
response.raise_for_status()
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
reply_stamp = response.headers.get(HEADER_TS, "")
check_stamp(reply_stamp)
return link.open_all(RESPONSE, path, reply_stamp, response.content)
def call_json(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None):
import json as _json
return _json.loads(call(client, method, url, payload=payload,
headers=headers, link=link))
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
w buforze zamiast zakładać, że każdy kawałek to komplet.
"""
if link is None:
yield from response.iter_bytes()
return
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
stamp = response.headers.get(HEADER_TS, "")
check_stamp(stamp)
path = response.request.url.path
buffer = bytearray()
seq = 0
for chunk in response.iter_bytes():
buffer += chunk
for frame in unframe_incremental(buffer):
yield link.open(RESPONSE, path, stamp, seq, frame)
seq += 1
if buffer:
raise LinkError("strumień urwał się w połowie ramki")
+5
View File
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from app import link_crypto, security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.factory import build_provider
@@ -24,6 +25,10 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
security.install(app, "danych") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
@app.post("/search", response_model=SearchResult)
+4 -1
View File
@@ -18,7 +18,10 @@ class SearchQuery(BaseModel):
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
value: str = Field(..., description="Szukana wartość.")
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
limit: int = Field(50, ge=1, le=50000)
# Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
# realnie potrzebuje build_report na jeden obiekt.
limit: int = Field(50, ge=1, le=5000)
fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
)
+50
View File
@@ -0,0 +1,50 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa danych oddaje SUROWE wiersze baz — to najbardziej wrażliwy punkt całego
systemu. Bez tego kontrolera wystarczyłoby uderzyć w nią bezpośrednio, z pominięciem
i logiki, i logowania w UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
+2
View File
@@ -7,3 +7,5 @@ openpyxl>=3.1
pyarrow>=18.0
SQLAlchemy>=2.0
pydantic>=2.10
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
+25 -1
View File
@@ -1,10 +1,34 @@
# Build wieloetapowy — bo `pyswisseph` to rozszerzenie C bez gotowych wheeli.
#
# Na PyPI (2.10.3.2) wheels kończą się na cp311 i obejmują wyłącznie i686/x86_64.
# Dla Pythona 3.12 oraz dla arm64 pip ZAWSZE kompiluje ze źródeł, a `-slim` nie ma
# kompilatora — dlatego jednoetapowy build tu padał. Kompilujemy w etapie builder,
# a do obrazu finalnego wchodzi już tylko gotowy wheel (bez toolchaina).
FROM python:3.12-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY . .
# Sanity check na etapie budowania: brak działającego swissepha ma wywalić build,
# a nie dopiero pierwszy request.
RUN python -c "import swisseph as swe; swe.set_ephe_path(None); print('swisseph OK', swe.version)"
EXPOSE 8003
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
+15 -1
View File
@@ -15,9 +15,23 @@ permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.*
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)
```bash
pip install -r requirements.txt
pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej)
uvicorn app.main:app --port 8003
```
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
+11 -2
View File
@@ -29,8 +29,14 @@ _PLANETS = {
"Sun": swe.SUN, "Moon": swe.MOON, "Mercury": swe.MERCURY, "Venus": swe.VENUS,
"Mars": swe.MARS, "Jupiter": swe.JUPITER, "Saturn": swe.SATURN,
"Uranus": swe.URANUS, "Neptune": swe.NEPTUNE, "Pluto": swe.PLUTO,
# punkty wirtualne — mean, jak w silniku własnym (parzystość LOG-28)
"North Node": swe.MEAN_NODE, "Lilith": swe.MEAN_APOG,
# "South Node" obsługiwany pochodnie w /positions: NN + 180°
}
DEFAULT_OBJECTS = list(_PLANETS)
DEFAULT_OBJECTS = [
"Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
"Uranus", "Neptune", "Pluto", "North Node", "South Node", "Lilith",
]
class PositionsRequest(BaseModel):
@@ -48,8 +54,11 @@ def positions(req: PositionsRequest) -> dict:
rows = []
for name in (req.objects or DEFAULT_OBJECTS):
xx, _retflag = swe.calc_ut(jd, _PLANETS[name], _FLAGS)
lookup = "North Node" if name == "South Node" else name
xx, _retflag = swe.calc_ut(jd, _PLANETS[lookup], _FLAGS)
lon, lat, _dist, lon_speed = xx[0], xx[1], xx[2], xx[3]
if name == "South Node":
lon, lat = lon + 180.0, -lat
rows.append({
"name": name,
"longitude": lon % 360.0,
+5 -1
View File
@@ -6,8 +6,12 @@ i nie w bazie.
## API
- `POST /api/query``QueryRequest``QueryResponse`
- `POST /chart/positions``{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne (LOG-06). `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). Zwraca też sektę i 7 Lots hermetycznych z domami (LOG-08). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
- `POST /chart/report``{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
- `POST /chart/profections``{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10)
- `POST /chart/return``{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
- `POST /chart/firdaria``{when_utc, lat, lon}` → Firdaria: sekta (dzień/noc), okresy główne i podokresy time-lordów (LOG-11)
- `POST /chart/timeline``{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc + Firdaria, posortowane (technique | significator | start | exact | end); z interpret=true dopina interpretacje z bazy do dat (LOG-14, 1B->2B)
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
- `GET /health` (sprawdza też warstwę bazodanową)
+2
View File
@@ -24,6 +24,8 @@ ABBREVIATIONS: dict[str, str] = {
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
# osie
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
# Lots (punkty arabskie)
"PF": "Part of Fortune", "Fortune": "Part of Fortune", "Spirit": "Lot of Spirit",
# aspekty
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
+21 -4
View File
@@ -5,13 +5,28 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza logika↔dane. Czytany przy każdym wywołaniu, bo konfiguracja
może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
key = link_crypto.key_from_env(link_crypto.ENV_LOGIC_DATA)
return link_crypto.Link(key) if key else None
class DataClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.data_url).rstrip("/")
@@ -26,12 +41,14 @@ class DataClient:
) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/search", json=payload)
r.raise_for_status()
return r.json()
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
payload=payload, headers=_auth_headers(),
link=_link())
def health(self) -> dict[str, Any]:
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
# nie mają, a nie przechodzi tędy nic z baz.
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health")
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
r.raise_for_status()
return r.json()
+45 -3
View File
@@ -27,6 +27,18 @@ LUMINARIES = {"Sun", "Moon"}
DEFAULT_ORB = 8.0
LUMINARY_BONUS = 2.0
# Pary sztywno powiązane definicyjnie — kąt między nimi wynika z samej definicji
# punktu, nie z układu nieba (SN = NN + 180°). Aspekt taki zawsze wychodzi
# dokładny (orb 0,00°) i nie niesie żadnej informacji astrologicznej, więc
# wycinamy go z wyników: zaśmieca listę w UI i zjada budżet promptu do LLM.
RIGID_PAIRS = frozenset({
frozenset({"North Node", "South Node"}),
})
def _is_rigid(name_a: str, name_b: str) -> bool:
return frozenset({name_a, name_b}) in RIGID_PAIRS
def separation(a: float, b: float) -> float:
"""Najmniejsza separacja kątowa [0,180]."""
@@ -34,15 +46,38 @@ def separation(a: float, b: float) -> float:
return min(d, 360.0 - d)
def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None:
"""Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)?
Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu
(pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying.
dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności.
Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć).
"""
if sa is None or sb is None:
return None
dev_now = abs(separation(la, lb) - angle)
dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle)
return dev_next < dev_now
def find_aspects(
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]:
"""positions: dicty z 'name' i 'decimal' (długość). Zwraca listę aspektów."""
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
"""
out: list[dict] = []
n = len(positions)
for i in range(n):
for j in range(i + 1, n):
a, b = positions[i], positions[j]
if _is_rigid(a["name"], b["name"]):
continue
la, lb = a.get("decimal"), b.get("decimal")
if la is None or lb is None:
continue
@@ -51,9 +86,16 @@ def find_aspects(
for asp, angle in MAJOR.items():
dev = abs(sep - angle)
if dev <= allowed:
out.append({
applying = _is_applying(
float(la), float(lb), a.get("speed"), b.get("speed"), angle
)
row = {
"obj1": a["name"], "obj2": b["name"],
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
})
}
if applying is not None:
row["applying"] = applying
row["as"] = "A" if applying else "S"
out.append(row)
break # jedna para = jeden aspekt
return out
+53 -11
View File
@@ -7,27 +7,51 @@ pozycje.
from __future__ import annotations
from app.engine import houses as H
from app.engine import zodiac as Z
from app.engine.base import EphemerisEngine
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
from app.engine.formats import SIGNS, absolute, decimal, in_sign, norm360, sign_index
from app.engine.models import ChartMoment
def _fmt(name: str, lon: float) -> dict:
def _fmt(name: str, lon: float, off: float = 0.0) -> dict:
lon = norm360(lon - off)
return {
"name": name,
"sign": SIGNS[sign_index(lon)],
"in_sign": in_sign(lon),
"decimal": round(norm360(lon), 6),
"decimal": round(lon, 6),
}
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict:
def _shift_pos(pdict: dict, off: float) -> None:
"""Przelicza etykiety pozycji na wybrany zodiak (in-place). off=0 → bez zmian."""
if not off:
return
lon = norm360(pdict["decimal"] - off)
pdict["sign"] = SIGNS[sign_index(lon)]
pdict["in_sign"] = in_sign(lon)
pdict["absolute"] = absolute(lon)
pdict["decimal"] = decimal(lon)
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
from app.engine.aspects import find_aspects
positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
result["zodiac"] = zodiac
if zodiac in Z.SIDEREAL:
result["ayanamsha"] = round(off, 6)
for pdict in result["positions"]:
_shift_pos(pdict, off)
if not hasattr(engine, "sidereal"):
return result
@@ -35,19 +59,37 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
asc = H.compute_asc(ramc, eps, moment.lat)
mc = H.compute_mc(ramc, eps)
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
cusp_list = H.cusps(asc, mc, system)
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
result["house_system"] = system
result["angles"] = {
"Asc": _fmt("Asc", asc),
"MC": _fmt("MC", mc),
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
"IC": _fmt("IC", norm360(mc + 180.0)),
"Asc": _fmt("Asc", asc, off),
"MC": _fmt("MC", mc, off),
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
"IC": _fmt("IC", norm360(mc + 180.0), off),
}
result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off))}
for i, c in enumerate(cusp_list)
]
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
from app.engine.firdaria import is_day_birth
from app.engine.lots import compute_lots
pts = {p.name: p.longitude for p in positions}
pts["Asc"] = asc
day = is_day_birth(pts["Sun"], asc, mc) if "Sun" in pts else True
result["sect"] = "day" if day else "night"
result["lots"] = [
{**lot,
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
"in_sign": in_sign(norm360(lot["longitude"] - off)),
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
for lot in compute_lots(pts, day, lots_method)
]
return result
+57
View File
@@ -0,0 +1,57 @@
"""Firdaria (LOG-11) — perska technika time-lord.
Sekwencja okresów głównych zależy od sekty (dzień/noc). Sekta: urodzenie dzienne,
gdy Słońce jest nad horyzontem, czyli po tej samej stronie osi AscDsc co MC.
Klasyczne długości okresów (lata): Su 10, Ve 8, Me 13, Mo 9, Sa 11, Ju 12, Ma 7
(razem 70) + Węzeł Północny 3 + Węzeł Południowy 2 = 75 lat. Każdy okres główny
planety dzieli się na 7 podokresów (sub-lord w tej samej kolejności, cyklicznie).
Węzły — bez podokresów (najczęstsza konwencja).
"""
from __future__ import annotations
from datetime import datetime, timedelta
DAY_ORDER = ["Sun", "Venus", "Mercury", "Moon", "Saturn", "Jupiter", "Mars"]
NIGHT_ORDER = ["Moon", "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury"]
YEARS = {"Sun": 10, "Venus": 8, "Mercury": 13, "Moon": 9,
"Saturn": 11, "Jupiter": 12, "Mars": 7}
NODES = [("North Node", 3), ("South Node", 2)]
DAYS_PER_YEAR = 365.2422
def is_day_birth(sun_lon: float, asc: float, mc: float) -> bool:
"""Słońce nad horyzontem = ta sama półkula osi AscDsc co MC."""
return (((sun_lon - asc) % 360.0) < 180.0) == (((mc - asc) % 360.0) < 180.0)
def _date(birth: datetime, years: float) -> str:
return (birth + timedelta(days=years * DAYS_PER_YEAR)).date().isoformat()
def firdaria(birth: datetime, sun_lon: float, asc: float, mc: float) -> dict:
"""Pełny rozkład Firdarii: sekta, kolejność, okresy główne i podokresy."""
day = is_day_birth(sun_lon, asc, mc)
order = DAY_ORDER if day else NIGHT_ORDER
majors = [(lord, YEARS[lord]) for lord in order] + NODES
periods: list[dict] = []
age = 0.0
for lord, yrs in majors:
period = {"lord": lord, "years": yrs,
"start": _date(birth, age), "end": _date(birth, age + yrs)}
if lord in YEARS: # planeta -> 7 podokresów
sub_len = yrs / 7.0
i = order.index(lord)
sub_age = age
subs: list[dict] = []
for k in range(7):
sub_lord = order[(i + k) % 7]
subs.append({"lord": sub_lord,
"start": _date(birth, sub_age),
"end": _date(birth, sub_age + sub_len)})
sub_age += sub_len
period["sub"] = subs
periods.append(period)
age += yrs
return {"sect": "day" if day else "night", "order": order, "periods": periods}
+60
View File
@@ -0,0 +1,60 @@
"""Lots / punkty arabskie (LOG-08) — 7 Lots hermetycznych.
Formuła: Lot = C + A B (od punktu C odmierzamy odległość między A i B).
Większość Lots **odwraca się w horoskopach nocnych** (zamiana A↔B) — np.
Fortuna: dzień Asc + Mo Su, noc Asc + Su Mo.
Dwa warianty liczenia (notes3):
- `degree` (domyślny) — dokładny stopień,
- `sign` — liczone całymi znakami (Lot wypada na 0° wyliczonego znaku).
Kolejność ma znaczenie: Fortuna i Duch liczone są pierwsze, bo pozostałe Lots
odwołują się do nich.
"""
from __future__ import annotations
from app.engine.formats import norm360, sign_index
# (nazwa, C, A, B, odwracalny w nocy)
LOT_DEFS: list[tuple[str, str, str, str, bool]] = [
("Fortune", "Asc", "Moon", "Sun", True),
("Spirit", "Asc", "Sun", "Moon", True),
("Eros", "Asc", "Venus", "Spirit", True),
("Necessity", "Asc", "Fortune", "Mercury", True),
("Courage", "Asc", "Fortune", "Mars", True),
("Victory", "Asc", "Jupiter", "Spirit", True),
("Nemesis", "Asc", "Fortune", "Saturn", True),
]
METHODS = ("degree", "sign")
def compute_lots(
points: dict[str, float], is_day: bool, method: str = "degree"
) -> list[dict]:
"""points: nazwa → długość natalna (wymagane Asc + planety formuł).
Zwraca listę {name, longitude, formula} w kolejności definicji.
"""
if method not in METHODS:
raise ValueError(f"nieznana metoda liczenia Lots: {method}")
vals = dict(points)
out: list[dict] = []
for name, c, a, b, reversible in LOT_DEFS:
first, second = (a, b) if (is_day or not reversible) else (b, a)
if any(k not in vals for k in (c, first, second)):
continue # brak składnika — pomijamy
if method == "sign":
idx = (sign_index(vals[c]) + sign_index(vals[first])
- sign_index(vals[second])) % 12
lon = idx * 30.0
else:
lon = norm360(vals[c] + vals[first] - vals[second])
vals[name] = lon # dostępny dla kolejnych Lots
out.append({
"name": name,
"longitude": lon,
"formula": f"{c} + {first} {second}",
})
return out
+3 -1
View File
@@ -13,10 +13,12 @@ from typing import Any
from app.engine import formats
# kanoniczny zestaw i kolejność obiektów (LOG-02: światła + 7 klasycznych + 3 nowożytne)
# kanoniczny zestaw i kolejność obiektów (LOG-02: światła + 7 klasycznych +
# 3 nowożytne + punkty wirtualne: węzły mean i mean Lilith)
DEFAULT_OBJECTS = [
"Sun", "Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
"North Node", "South Node", "Lilith",
]
+43
View File
@@ -0,0 +1,43 @@
"""Punkty wirtualne liczone analitycznie (LOG-02): mean Node i mean Lilith.
Wzory Meeusa (Astronomical Algorithms) w stuleciach juliańskich od J2000 (TT):
- Ω — średni węzeł wstępujący orbity Księżyca (mean ascending node). Porusza się
zawsze wstecz (~0,053°/dobę) — stąd węzły są wiecznie Rx.
- średnie perygeum orbity Księżyca; mean Lilith (Black Moon) = średnie APOGEUM
= perygeum + 180° (~+0,111°/dobę).
Wersje TRUE (oskulacyjne) — osobny, późniejszy krok (notatki: mean to
historyczny standard i domyślne ustawienie programów).
"""
from __future__ import annotations
from app.engine.formats import norm360
_DAYS_PER_CENTURY = 36525.0
def _t(tt_jd: float) -> float:
return (tt_jd - 2451545.0) / _DAYS_PER_CENTURY
def mean_lunar_node(tt_jd: float) -> float:
"""Długość ekliptyczna średniego Węzła Północnego (Ω) [°]."""
t = _t(tt_jd)
omega = (125.0445479 - 1934.1362891 * t + 0.0020754 * t * t
+ t ** 3 / 467441.0 - t ** 4 / 60616000.0)
return norm360(omega)
def mean_lilith(tt_jd: float) -> float:
"""Długość ekliptyczna mean Lilith (średnie apogeum Księżyca) [°]."""
t = _t(tt_jd)
perigee = (83.3532465 + 4069.0137287 * t - 0.0103200 * t * t
- t ** 3 / 80053.0 + t ** 4 / 18999000.0)
return norm360(perigee + 180.0)
def point_speed(fn, tt_jd: float, dt_days: float = 0.1) -> float:
"""Prędkość [°/dobę] punktu analitycznego — różnica po małym kroku."""
a = fn(tt_jd)
b = fn(tt_jd + dt_days)
return (((b - a + 180.0) % 360.0) - 180.0) / dt_days
+70
View File
@@ -0,0 +1,70 @@
"""Profekcje roczne (LOG-10) — hellenistyczna technika time-lord.
Zasada (Whole Sign): co każde urodziny profektowany Ascendent przeskakuje o jeden
znak do przodu (wiek mod 12). Władca Roku (Lord of Year) = władca domicylowy
znaku profektowanego Asc. Profektować można każdy punkt natalny (MC, Słońce…) —
wszystkie przeskakują o tyle samo znaków.
Referencja: tabela profekcji w notes3 (astro-seek) dla horoskopu 30.04.1984
(wiek 0: Can/Moon, 1: Leo/Sun, …, 42: Cap/Saturn).
"""
from __future__ import annotations
from datetime import datetime
from app.engine.formats import SIGNS, sign_index
# władcy domicylowi (tradycyjni) — zgodni z tabelą referencyjną notes3
DOMICILE_RULERS = {
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon",
"Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars",
"Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn",
"Pisces": "Jupiter",
}
def age_at(birth_utc: datetime, when_utc: datetime) -> int:
"""Pełne lata między urodzeniem a danym momentem (wiek profekcyjny)."""
age = when_utc.year - birth_utc.year
if (when_utc.month, when_utc.day) < (birth_utc.month, birth_utc.day):
age -= 1
return max(age, 0)
def profected_sign(natal_lon: float, age: int) -> str:
"""Znak, do którego profektował punkt natalny po `age` latach."""
return SIGNS[(sign_index(natal_lon) + age) % 12]
def profection_rows(
natal_points: dict[str, float],
birth_utc: datetime,
start_age: int,
count: int,
) -> list[dict]:
"""Tabela profekcji dla zakresu lat życia.
natal_points: nazwa -> natalna długość ekliptyczna (musi zawierać 'Asc').
Każdy wiersz: wiek, data początku roku profekcyjnego (urodziny), znak
profektowanego Asc, Władca Roku oraz profekcje pozostałych punktów.
"""
def _birthday(year: int) -> datetime:
try:
return birth_utc.replace(year=year)
except ValueError: # 29 lutego w roku nieprzestępnym
return birth_utc.replace(year=year, day=28)
rows: list[dict] = []
for age in range(start_age, start_age + count):
asc_sign = profected_sign(natal_points["Asc"], age)
row = {
"age": age,
"from": _birthday(birth_utc.year + age).strftime("%Y-%m-%d"),
"profected_asc": asc_sign,
"lord_of_year": DOMICILE_RULERS[asc_sign],
}
for name, lon in natal_points.items():
if name != "Asc":
row[name] = profected_sign(lon, age)
rows.append(row)
return rows
+64
View File
@@ -0,0 +1,64 @@
"""Solar / Lunar Return (LOG-12) — moment powrotu do pozycji natalnej.
Solar Return (solariusz): moment, w którym Słońce wraca dokładnie do natalnej
długości ekliptycznej (raz na rok, w okolicy urodzin). Lunar Return: to samo
dla Księżyca (raz na ~27,3 dnia). Dwa warianty użycia (osobny horoskop vs
tranzyt do natalu) obsługujemy zwracając pełny horoskop na znaleziony moment —
interpretacja pozostaje po stronie technik wyżej.
Metoda: podpisana różnica długości Δ = lon natal (zawinięta do ±180°) rośnie
monotonicznie i przechodzi przez 0 dokładnie w momencie powrotu. Skan dobowy
wykrywa przejście −→+ (skok +180→−180 to artefakt zawinięcia — pomijany,
warunek d_hi d_lo < 180), potem bisekcja do ~sekundy.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from app.engine.models import ChartMoment
# szerokość okna skanu wokół `around` [dni]: solar kotwiczymy przy urodzinach,
# lunar musi objąć cały okres syderyczny Księżyca (27,3 d)
SCAN_WINDOW = {"solar": 6.0, "lunar": 15.0}
def _lon_delta(engine, body: str, natal_lon: float, when: datetime) -> float:
m = ChartMoment(when_utc=when)
lon = engine.positions(m, [body])[0].longitude
return ((lon - natal_lon + 180.0) % 360.0) - 180.0
def find_return(
engine, kind: str, natal_moment: ChartMoment, around: datetime
) -> datetime | None:
"""Moment powrotu (kind: 'solar'/'lunar') najbliższy dacie `around`."""
body = "Sun" if kind == "solar" else "Moon"
natal_lon = engine.positions(natal_moment, [body])[0].longitude
if around.tzinfo is None:
around = around.replace(tzinfo=timezone.utc)
window = SCAN_WINDOW[kind]
step = timedelta(days=1.0)
t = around - timedelta(days=window)
end = around + timedelta(days=window)
candidates: list[datetime] = []
d_prev = _lon_delta(engine, body, natal_lon, t)
while t < end:
t_next = t + step
d_next = _lon_delta(engine, body, natal_lon, t_next)
# prawdziwe przejście przez zero: -> + bez skoku zawinięcia
if d_prev < 0 <= d_next and (d_next - d_prev) < 180.0:
lo, hi, d_lo = t, t_next, d_prev
for _ in range(40): # bisekcja do ułamka sekundy
mid = lo + (hi - lo) / 2
if (_lon_delta(engine, body, natal_lon, mid) < 0) == (d_lo < 0):
lo = mid
else:
hi = mid
candidates.append(lo + (hi - lo) / 2)
t, d_prev = t_next, d_next
if not candidates:
return None
return min(candidates, key=lambda c: abs(c - around))
@@ -60,6 +60,27 @@ class SkyfieldEngine(EphemerisEngine):
lat, lon, _dist = astrometric.ecliptic_latlon(epoch="date")
return lon.degrees, lat.degrees
def _virtual_point(self, name: str, tt_jd: float) -> ObjectPosition:
"""Punkty analityczne (LOG-02): mean Node (NN/SN) i mean Lilith.
Liczone wzorami Meeusa, nie z jądra JPL. SN = NN + 180° (ta sama prędkość).
Punkty leżą na ekliptyce (latitude = 0).
"""
from app.engine.points import mean_lilith, mean_lunar_node, point_speed
if name in ("North Node", "South Node"):
lon = mean_lunar_node(tt_jd)
if name == "South Node":
lon = norm360(lon + 180.0)
speed = point_speed(mean_lunar_node, tt_jd)
else: # Lilith
lon = mean_lilith(tt_jd)
speed = point_speed(mean_lilith, tt_jd)
return ObjectPosition(
name=name, longitude=float(lon), latitude=0.0,
speed=float(speed), retrograde=bool(speed < 0),
)
def positions(
self, moment: ChartMoment, objects: list[str] | None = None
) -> list[ObjectPosition]:
@@ -70,6 +91,9 @@ class SkyfieldEngine(EphemerisEngine):
out: list[ObjectPosition] = []
for name in names:
if name not in _TARGETS: # punkt wirtualny (NN/SN/Lilith)
out.append(self._virtual_point(name, t.tt))
continue
target = self.eph[_TARGETS[name]]
lon, lat = self._ecliptic_lon_lat(target, t)
lon2, _ = self._ecliptic_lon_lat(target, t2)
+100
View File
@@ -0,0 +1,100 @@
"""Wykrywanie stacji planet (LOG-03): poprzednia/następna stacja, SD/SR, flaga <7 dni.
Stacja ścisła = moment, w którym prędkość zodiakalna przechodzi przez zero.
Metoda: próbki prędkości co 1 dzień w oknie ± SEARCH_DAYS → zmiana znaku →
bisekcja do dokładności ~1 minuty. Klasyfikacja: prędkość przed<0 i po>0 → SD
(stationary direct), odwrotnie → SR (stationary retrograde).
Pomijamy Słońce/Księżyc (nigdy Rx) i punkty mean (NN/SN/Lilith — ruch jednostajny).
"""
from __future__ import annotations
from datetime import timedelta
from app.engine.formats import in_sign
from app.engine.models import ChartMoment
# obiekty bez stacji
NO_STATIONS = {"Sun", "Moon", "North Node", "South Node", "Lilith"}
# Okno musi pokryć najdłuższą przerwę między stacjami (Mars/Wenus: ~700 dni),
# a krok skanu musi być krótszy niż najkrótsza retrogradacja (Merkury ~21 dni).
SEARCH_DAYS = 800 # okno poszukiwań w każdą stronę
SCAN_STEP_DAYS = 4.0 # krok zgrubnego skanu (potem bisekcja)
STATION_SOON_DAYS = 7.0 # próg flagi "stacja blisko" (konfigurowalny, notes2)
def _speed_fn(engine, name: str):
"""Zwraca funkcję: dni_od_momentu_bazowego -> prędkość [°/dobę]."""
def speed(base_moment: ChartMoment, offset_days: float) -> float:
m = ChartMoment(
when_utc=base_moment.when_utc + timedelta(days=offset_days),
lat=base_moment.lat, lon=base_moment.lon,
)
return engine.positions(m, [name])[0].speed
return speed
def _bisect_zero(speed, moment: ChartMoment, lo: float, hi: float, iters: int = 20) -> float:
"""Bisekcja miejsca zerowego prędkości między dniami lo i hi."""
s_lo = speed(moment, lo)
for _ in range(iters):
mid = (lo + hi) / 2.0
s_mid = speed(moment, mid)
if (s_lo < 0) == (s_mid < 0):
lo, s_lo = mid, s_mid
else:
hi = mid
return (lo + hi) / 2.0
def _station_info(engine, moment: ChartMoment, name: str, day: float, speed) -> dict:
"""Opis stacji w danym dniu (offset od momentu bazowego)."""
before = speed(moment, day - 0.5)
kind = "SD" if before < 0 else "SR"
when = moment.when_utc + timedelta(days=day)
m = ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
lon = engine.positions(m, [name])[0].longitude
return {
"type": kind,
"date": when.strftime("%Y-%m-%d %H:%M"),
"days": round(day, 1), # ujemne = w przeszłości
"degree": in_sign(lon),
}
def find_stations(engine, moment: ChartMoment, name: str, step_days: float = SCAN_STEP_DAYS) -> dict | None:
"""Poprzednia i następna stacja obiektu względem momentu horoskopu."""
if name in NO_STATIONS:
return None
speed = _speed_fn(engine, name)
prev_day = next_day = None
# w przeszłość
s_right = speed(moment, 0.0)
d = 0.0
while d > -SEARCH_DAYS:
s_left = speed(moment, d - step_days)
if (s_left < 0) != (s_right < 0):
prev_day = _bisect_zero(speed, moment, d - step_days, d)
break
d, s_right = d - step_days, s_left
# w przyszłość
s_left = speed(moment, 0.0)
d = 0.0
while d < SEARCH_DAYS:
s_right = speed(moment, d + step_days)
if (s_left < 0) != (s_right < 0):
next_day = _bisect_zero(speed, moment, d, d + step_days)
break
d, s_left = d + step_days, s_right
result: dict = {}
if prev_day is not None:
result["prev"] = _station_info(engine, moment, name, prev_day, speed)
if next_day is not None:
result["next"] = _station_info(engine, moment, name, next_day, speed)
result["station_soon"] = any(
abs(x["days"]) < STATION_SOON_DAYS for x in result.values() if isinstance(x, dict)
)
return result or None
+160
View File
@@ -0,0 +1,160 @@
"""Zbiorcza tabela dat z technik (LOG-14).
Spina w jedną, posortowaną oś czasu daty z kilku technik:
- profekcje roczne (LOG-10) — rok życia,
- Solar Return (LOG-12) — moment powrotu Słońca,
- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów
natalnych (wzorzec z notes3: „Profection planet | Aspect | Birth planet |
Exact Date"). Klucz łuku konfigurowalny; domyślnie Naiboda (0°59'08"/rok).
Każdy wiersz ma kształt z notes2: technique | significator | start | exact | end.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from app.engine.aspects import MAJOR, PL_NAME
from app.engine.profections import DOMICILE_RULERS, profected_sign
from app.engine.returns import find_return
NAIBOD_KEY = 0.9856472 # °/rok (0°59'08") — domyślny klucz solar-arc
DAYS_PER_YEAR = 365.2422
DIRECTED = ["Sun", "Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"]
def _add_years(birth: datetime, years: float) -> datetime:
return birth + timedelta(days=years * DAYS_PER_YEAR)
def _row(technique, significator, start, exact, end) -> dict:
def iso(x):
return x.date().isoformat() if isinstance(x, datetime) else x
return {"technique": technique, "significator": significator,
"start": iso(start), "exact": iso(exact), "end": iso(end)}
def solar_arc_directions(
natal: dict[str, float], birth: datetime, lo: datetime, hi: datetime,
key: float = NAIBOD_KEY, orb_years: float = 1.0,
) -> list[dict]:
"""Daty dyrekcji solar-arc w oknie [lo, hi].
natal: nazwa punktu -> długość natalna (planety + Asc/MC). Kierowane są planety
(DIRECTED), celem każdy punkt natalny. Aspekt dokładny gdy łuk = odległość
kątowa (mod 360). Wiek = łuk/klucz; data = urodziny + wiek.
"""
out: list[dict] = []
lo_age = (lo - birth).days / DAYS_PER_YEAR - orb_years
hi_age = (hi - birth).days / DAYS_PER_YEAR + orb_years
for p in DIRECTED:
if p not in natal:
continue
for q, q_lon in natal.items():
for asp, angle in MAJOR.items():
for target in ({angle, (360.0 - angle) % 360.0}):
arc = (q_lon + target - natal[p]) % 360.0
age = arc / key
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
continue
exact = _add_years(birth, age)
row = _row(
"solar_arc",
f"dyr. {p} {PL_NAME[asp]} {q}",
_add_years(birth, age - orb_years),
exact,
_add_years(birth, age + orb_years),
)
row.update(directed=p, aspect=asp, target=q) # do budowy tokenów (1B->2B)
out.append(row)
return out
def profection_events(natal_asc: float, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
"""Lata profekcyjne (LOG-10) nachodzące na okno."""
out: list[dict] = []
for age in range((lo.year - birth.year) - 1, (hi.year - birth.year) + 1):
if age < 0:
continue
try:
start = birth.replace(year=birth.year + age)
end = birth.replace(year=birth.year + age + 1)
except ValueError: # 29 lutego
start = birth.replace(year=birth.year + age, day=28)
end = birth.replace(year=birth.year + age + 1, day=28)
if end < lo or start > hi:
continue
sign = profected_sign(natal_asc, age)
lord = DOMICILE_RULERS[sign]
row = _row(
"profection", f"Władca Roku: {lord} (Asc {sign}, wiek {age})",
start, start, end,
)
row.update(lord=lord, sign=sign) # do budowy tokenów (1B->2B)
out.append(row)
return out
def solar_return_events(engine, natal_moment, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
"""Solariusze w oknie (LOG-12) — jeden na rok."""
out: list[dict] = []
for year in range(lo.year, hi.year + 1):
try:
around = birth.replace(year=year)
except ValueError:
around = birth.replace(year=year, day=28)
hit = find_return(engine, "solar", natal_moment, around)
if hit and lo <= hit <= hi:
out.append(_row("solar_return", "Solar Return", hit, hit, _add_years(hit, 1)))
return out
def firdaria_events(natal_points: dict[str, float], birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
"""Starty okresów/podokresów Firdarii (LOG-11) nachodzące na okno."""
from app.engine.firdaria import firdaria
fd = firdaria(birth, natal_points["Sun"], natal_points["Asc"], natal_points["MC"])
out: list[dict] = []
for period in fd["periods"]:
if "sub" in period:
for s in period["sub"]:
if lo <= _as_dt(s["start"]) <= hi:
row = _row("firdaria", f"Firdaria: {period['lord']} / {s['lord']}",
s["start"], s["start"], s["end"])
row.update(fd_major=period["lord"], fd_sub=s["lord"])
out.append(row)
elif lo <= _as_dt(period["start"]) <= hi: # węzeł — bez podokresów
row = _row("firdaria", f"Firdaria: {period['lord']}",
period["start"], period["start"], period["end"])
row.update(fd_major=period["lord"])
out.append(row)
return out
def _as_dt(d) -> datetime:
if isinstance(d, datetime):
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
if isinstance(d, date):
return datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
return datetime.fromisoformat(str(d)).replace(tzinfo=timezone.utc)
def build_timeline(
engine, natal_moment, natal_points: dict[str, float],
from_d, to_d, techniques: list[str] | None = None,
) -> list[dict]:
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
lo, hi = _as_dt(from_d), _as_dt(to_d)
birth = natal_moment.when_utc
want = set(techniques or ["profection", "solar_return", "solar_arc", "firdaria"])
events: list[dict] = []
if "profection" in want:
events += profection_events(natal_points["Asc"], birth, lo, hi)
if "solar_return" in want:
events += solar_return_events(engine, natal_moment, birth, lo, hi)
if "solar_arc" in want:
events += solar_arc_directions(natal_points, birth, lo, hi)
if "firdaria" in want:
events += firdaria_events(natal_points, birth, lo, hi)
events.sort(key=lambda e: e["exact"])
return events
+103
View File
@@ -0,0 +1,103 @@
"""Systemy zodiaku (LOG-04): tropikalny, syderyczny (ayanamsy), draconic + RA.
Wszystkie pozycje silnika są liczone **tropikalnie of-date** (kontrakt LOG-28).
Zmiana zodiaku to — dla zodiaków ekliptycznych — jednolite przesunięcie długości:
długość_docelowa = (długość_tropikalna offset) mod 360
gdzie offset to:
- **syderyczny**: ayanamsa (kąt między tropikalnym a syderycznym punktem Barana),
- **draconic**: długość wznoszącego węzła Księżyca (węzeł = 0° draconic),
- **tropikalny**: 0.
Ponieważ to stałe przesunięcie obiektu ORAZ cusps, **numery domów się nie zmieniają**
(geometria jest niezmiennicza względem obrotu) — przesuwamy tylko etykiety znaków.
Model ayanamsy: `ayan(jd) = ayan0 + B·x + C·x²`, gdzie `x = jd J2000`. Prędkość
precesji (B, C) jest **wspólna** dla wszystkich ayanams; różni je tylko stała `ayan0`
(wybór syderycznego zera). Stałe skalibrowano do Swiss Ephemeris jako wyroczni —
zgodność do ~0,02" w latach 19002100 (patrz tests/test_zodiac.py).
RA (right ascension): konwersja ekliptyka→równik dla przyszłego widoku równikowego.
"""
from __future__ import annotations
import math
from datetime import datetime
from app.engine.formats import norm360
TROPICAL = "tropical"
DRACONIC = "draconic"
# stała ayanamsy w J2000.0 (°) — skalibrowana do swisseph (get_ayanamsa_ut)
_AYAN0 = {
"lahiri": 23.857092,
"fagan_bradley": 24.740300,
"krishnamurti": 23.760240,
}
_J2000 = 2451545.0
_B = 3.824459e-5 # °/dobę — liniowy człon precesji (wspólny)
_C = 2.304e-13 # °/dobę² — drobne przyspieszenie (wspólne)
# nazwy zodiaków akceptowane przez API
SIDEREAL = tuple(f"sidereal_{k}" for k in _AYAN0) # sidereal_lahiri, ...
SYSTEMS = (TROPICAL, *SIDEREAL, DRACONIC)
def julian_day(dt: datetime) -> float:
"""Julian Day (UT) z momentu UTC — algorytm Meeusa (kalendarz gregoriański)."""
y, m = dt.year, dt.month
day = dt.day + (dt.hour + dt.minute / 60.0 + dt.second / 3600.0
+ dt.microsecond / 3.6e9) / 24.0
if m <= 2:
y -= 1
m += 12
a = y // 100
b = 2 - a + a // 4
return math.floor(365.25 * (y + 4716)) + math.floor(30.6001 * (m + 1)) + day + b - 1524.5
def ayanamsha(name: str, jd: float) -> float:
"""Ayanamsa [°] danej szkoły dla Julian Day (UT)."""
key = name[len("sidereal_"):] if name.startswith("sidereal_") else name
if key not in _AYAN0:
raise ValueError(f"Nieznana ayanamsa: {name!r} (dostępne: {', '.join(_AYAN0)})")
x = jd - _J2000
return _AYAN0[key] + _B * x + _C * x * x
def offset(zodiac: str, jd: float, node_lon: float | None = None) -> float:
"""Ile odjąć od długości tropikalnej, by dostać wybrany zodiak.
`node_lon` (tropikalna długość węzła wznoszącego) wymagana tylko dla draconic.
"""
if zodiac == TROPICAL:
return 0.0
if zodiac == DRACONIC:
if node_lon is None:
raise ValueError("draconic wymaga długości węzła (node_lon)")
return norm360(node_lon)
if zodiac in SIDEREAL:
return ayanamsha(zodiac, jd)
raise ValueError(f"Nieznany zodiak: {zodiac!r} (dostępne: {', '.join(SYSTEMS)})")
def apply(lon: float, off: float) -> float:
"""Długość w docelowym zodiaku."""
return norm360(lon - off)
def to_equatorial(lon: float, lat: float, eps: float) -> tuple[float, float]:
"""Ekliptyka (λ, β) → równik: (RA, deklinacja) w stopniach. Wszystko w °.
RA rośnie 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)
+466
View File
@@ -0,0 +1,466 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
try:
return self._by_direction[direction].decrypt(
nonce, sealed, self._aad(direction, path, stamp, seq))
except InvalidTag as exc:
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
# ------------------------------------------------------------ strumień ramek
def seal_stream(self, direction: bytes, path: str, stamp: str,
chunks: Iterable[bytes]) -> Iterator[bytes]:
for seq, chunk in enumerate(chunks):
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
def open_stream(self, direction: bytes, path: str, stamp: str,
raw: bytes) -> Iterator[bytes]:
for seq, frame in enumerate(frames_in(raw)):
yield self.open(direction, path, stamp, seq, frame)
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
return b"".join(self.open_stream(direction, path, stamp, raw))
# ---------------------------------------------------------------- ramkowanie
def frame_out(payload: bytes) -> bytes:
return struct.pack(">I", len(payload)) + payload
def frames_in(raw: bytes) -> Iterator[bytes]:
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
offset = 0
while offset < len(raw):
if offset + LENGTH_PREFIX > len(raw):
raise LinkError("urwana ramka (brak nagłówka długości)")
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
offset += LENGTH_PREFIX
if offset + size > len(raw):
raise LinkError("urwana ramka (za mało danych)")
yield raw[offset:offset + size]
offset += size
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
rozjechać się między dwa odczyty.
"""
while True:
if len(buffer) < LENGTH_PREFIX:
return
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
if len(buffer) < LENGTH_PREFIX + size:
return
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
del buffer[:LENGTH_PREFIX + size]
yield frame
# ------------------------------------------------------------- świeżość ruchu
def stamp_now() -> str:
return f"{time.time():.3f}"
def check_stamp(stamp: str) -> None:
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
odtworzyć w dowolnym momencie w przyszłości."""
try:
sent = float(stamp)
except (TypeError, ValueError) as exc:
raise LinkError("brak albo błędny znacznik czasu") from exc
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
# =========================================================== strona serwerowa
class LinkCryptoMiddleware:
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
"""
def __init__(self, app, link: Link | None, layer: str) -> None:
self.app = app
self.link = link
self.layer = layer
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
return await self.app(scope, receive, send)
path = scope["path"]
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
if headers.get(HEADER_ENC.lower()) != VERSION:
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
# chcemy po cichu wrócić do jawnego ruchu.
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
self.layer, path)
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
stamp = headers.get(HEADER_TS.lower(), "")
try:
check_stamp(stamp)
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
except LinkError as exc:
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
scope = dict(scope)
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
def _sealing_send(self, send, path: str):
state: dict = {"stamp": "", "seq": 0}
async def sealing(message):
if message["type"] == "http.response.start":
state["stamp"] = stamp_now()
keep = [(k, v) for k, v in message.get("headers", [])
if k.lower() not in (b"content-length", b"content-type")]
message = dict(message)
message["headers"] = keep + [
(b"content-type", CONTENT_TYPE.encode()),
(HEADER_ENC.lower().encode(), VERSION.encode()),
(HEADER_TS.lower().encode(), state["stamp"].encode()),
]
return await send(message)
if message["type"] == "http.response.body":
chunk = message.get("body", b"")
sealed = b""
if chunk:
sealed = frame_out(self.link.seal(
RESPONSE, path, state["stamp"], state["seq"], chunk))
state["seq"] += 1
return await send({"type": "http.response.body", "body": sealed,
"more_body": message.get("more_body", False)})
return await send(message)
return sealing
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
kept.append((b"content-length", str(length).encode()))
if length:
kept.append((b"content-type", b"application/json"))
return kept
async def _read_body(receive) -> bytes:
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
raise LinkError("rozłączenie w trakcie odbioru żądania")
body += message.get("body", b"")
if not message.get("more_body", False):
return bytes(body)
def _replay(body: bytes, original):
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
"""
delivered = False
async def receive():
nonlocal delivered
if delivered:
return await original()
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return receive
async def _refuse(send, detail: str) -> None:
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
await send({"type": "http.response.start", "status": 400, "headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
]})
await send({"type": "http.response.body", "body": payload})
def install(app, env_name: str, layer: str):
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
link_key = key_from_env(env_name)
if link_key is None and encryption_required():
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
f"warstwa {layer} nie wystartuje bez klucza łącza"
)
if link_key is None:
log.warning(
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
"ruch wewnątrz sieci.", env_name, layer,
)
return None
link = Link(link_key)
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
return link
# ============================================================ strona kliencka
def call(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
się niejasnym błędem odszyfrowania.
"""
import json as _json
import httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
# sygnifikatory, o które pytamy bazę.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
)
response = client.request(method, url, json=payload, headers=request_headers)
response.raise_for_status()
return response.content
path = httpx.URL(url).path
stamp = stamp_now()
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
"Content-Type": CONTENT_TYPE})
response = client.request(method, url, content=body, headers=request_headers)
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
log.error("łącze %s odmówiło: %s", path, response.text[:200])
response.raise_for_status()
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
reply_stamp = response.headers.get(HEADER_TS, "")
check_stamp(reply_stamp)
return link.open_all(RESPONSE, path, reply_stamp, response.content)
def call_json(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None):
import json as _json
return _json.loads(call(client, method, url, payload=payload,
headers=headers, link=link))
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
w buforze zamiast zakładać, że każdy kawałek to komplet.
"""
if link is None:
yield from response.iter_bytes()
return
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
stamp = response.headers.get(HEADER_TS, "")
check_stamp(stamp)
path = response.request.url.path
buffer = bytearray()
seq = 0
for chunk in response.iter_bytes():
buffer += chunk
for frame in unframe_incremental(buffer):
yield link.open(RESPONSE, path, stamp, seq, frame)
seq += 1
if buffer:
raise LinkError("strumień urwał się w połowie ramki")
+1
View File
@@ -0,0 +1 @@
"""Warstwa dostawców modeli językowych (LOG-31)."""
+42
View File
@@ -0,0 +1,42 @@
"""Kontrakt dostawcy modelu językowego (LOG-31).
Analogicznie do `EphemerisEngine` (LOG-24): prezentacja i reszta logiki nie wiedzą,
kto pisze tekst — lokalny model na naszym sprzęcie czy dostawca w chmurze.
Kluczowa własność dla bezpieczeństwa baz (LOG-32): każdy dostawca deklaruje
`leaves_lan`. Prompt niesie ORYGINALNE opisy z baz, więc interfejs musi jawnie
mówić, czy ta treść opuszcza naszą sieć — UI ma na tej podstawie ostrzegać.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@dataclass
class Completion:
"""Wynik generowania — tekst + metryki do rozliczenia i podglądu."""
text: str
model: str
provider: str
leaves_lan: bool
usage: dict = field(default_factory=dict) # prompt_tokens / completion_tokens
class LLMError(RuntimeError):
"""Błąd wołania modelu — z komunikatem nadającym się do pokazania użytkownikowi."""
class LLMProvider(ABC):
name: str = "?"
#: czy treść promptu (a więc opisy z baz) opuszcza naszą sieć
leaves_lan: bool = True
@abstractmethod
def generate(self, prompt: str, max_tokens: int) -> Completion:
"""Zwraca gotowy tekst. Rzuca LLMError przy niepowodzeniu."""
@abstractmethod
def health(self) -> dict:
"""Czy dostawca jest osiągalny i skonfigurowany."""
+75
View File
@@ -0,0 +1,75 @@
"""Katalog modeli do wyboru w UI (LOG-31).
To są **podpowiedzi**, nie zamknięta lista. Pole modelu w UI jest tekstowe z
datalistą, więc można wpisać dowolny identyfikator — konto może mieć dostęp do
modeli, których tu nie ma, a nowe wychodzą szybciej, niż aktualizuje się kod.
Puste pole = model domyślny dostawcy.
Uwaga o pewności danych:
* modele **Anthropic** pochodzą z oficjalnej dokumentacji API (okna kontekstu
i limity wyjścia zgadzają się z `app/llm/limits.py`);
* modele **OpenAI** to podpowiedzi — nie weryfikowałem ich katalogu, więc
traktuj je jako wygodę, a nie źródło prawdy;
* modele **lokalne** zależą wyłącznie od tego, co masz pobrane w Ollamie/vLLM.
Katalog można nadpisać/rozszerzyć zmienną `<DOSTAWCA>_MODELS` (lista po przecinku),
np. `OPENAI_MODELS="gpt-5,gpt-4o"`.
"""
from __future__ import annotations
import os
from app.llm.limits import limits_for
# dostawca -> [(id modelu, krótki opis dla człowieka)]
_CATALOG: dict[str, list[tuple[str, str]]] = {
"anthropic": [
("claude-opus-4-8", "Opus 4.8 — domyślny, bardzo zdolny, 1M kontekstu"),
("claude-fable-5", "Fable 5 — najbardziej zdolny, do najtrudniejszych zadań"),
("claude-sonnet-5", "Sonnet 5 — szybszy i tańszy, jakość blisko Opusa"),
("claude-opus-4-7", "Opus 4.7 — poprzednia generacja Opusa"),
("claude-haiku-4-5", "Haiku 4.5 — najszybszy i najtańszy, mniejsze okno"),
],
"openai": [
("gpt-4o-mini", "GPT-4o mini — tani i szybki"),
("gpt-4o", "GPT-4o"),
("gpt-5", "GPT-5 — jeśli Twoje konto ma dostęp"),
("gpt-4.1", "GPT-4.1"),
("gpt-4.1-mini", "GPT-4.1 mini"),
],
"local": [
("llama3.1:8b", "Llama 3.1 8B"),
("llama3.2", "Llama 3.2"),
("qwen2.5", "Qwen 2.5 — większe okno kontekstu"),
("mistral", "Mistral"),
],
}
def models_for(provider: str) -> list[dict]:
"""Podpowiedzi modeli dla dostawcy, wraz z oknem kontekstu.
Okno kontekstu podajemy, bo wprost przekłada się na opcję „maksymalny
kontekst modelu" — użytkownik widzi, na ile budżetu promptu może liczyć.
"""
override = os.getenv(f"{provider.upper()}_MODELS", "").strip()
if override:
entries = [(m.strip(), "") for m in override.split(",") if m.strip()]
else:
entries = _CATALOG.get(provider, [])
out = []
for model_id, label in entries:
context_window, max_output = limits_for(provider, model_id)
out.append({
"id": model_id,
"label": label or model_id,
"context_window": context_window,
"max_output": max_output,
})
return out
def catalog() -> dict[str, list[dict]]:
"""Pełny katalog dla UI — jedno żądanie zamiast trzech."""
return {provider: models_for(provider) for provider in ("local", "anthropic", "openai")}
+121
View File
@@ -0,0 +1,121 @@
"""Wybór dostawcy LLM (LOG-31) — jedyne miejsce znające konkretne implementacje.
Domyślny jest **model lokalny**: prompt niesie oryginalne opisy z baz, więc
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
przez konfigurację albo pojedyncze żądanie.
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
Zmienne środowiskowe:
LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
LLM_TIMEOUT sekundy (domyślnie 120)
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
<DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
LOCAL_MODEL, LOCAL_BASE_URL (klucz zwykle zbędny)
OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY
ANTHROPIC_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY
Klucze WYŁĄCZNIE z sekretu — nigdy w repo, w UI ani w logach.
Zgodność wstecz: wspólne `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` nadal działają,
ale stosują się TYLKO do dostawcy domyślnego (LLM_PROVIDER) — czyli konfiguracja
instalacji jednodostawcowej zostaje nietknięta, a pozostali dostawcy jej nie dziedziczą.
"""
from __future__ import annotations
import os
from app.llm.base import LLMError, LLMProvider
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
LOCAL = "local"
OPENAI = "openai"
ANTHROPIC = "anthropic"
PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
_DEFAULT_MODEL = {
LOCAL: "llama3.1:8b",
OPENAI: "gpt-4o-mini",
# Opus 4.8 świadomie zamiast Sonnet 5: Sonnet uruchamia myślenie adaptacyjne,
# gdy pominąć parametr `thinking`, a jego tokeny liczą się do max_tokens —
# przy ciasnym limicie cała tura wychodziła jako samo myślenie z pustym
# tekstem. To była przyczyna pustych odpowiedzi na Anthropicu.
ANTHROPIC: "claude-opus-4-8",
}
_DEFAULT_URL = {
# Ollama i vLLM wystawiają zgodne API pod /v1
LOCAL: "http://localhost:11434/v1",
OPENAI: "https://api.openai.com/v1",
ANTHROPIC: "https://api.anthropic.com",
}
def default_provider_name() -> str:
return os.getenv("LLM_PROVIDER", LOCAL).lower()
def max_tokens() -> int:
return int(os.getenv("LLM_MAX_TOKENS", "2000"))
def timeout() -> float:
return float(os.getenv("LLM_TIMEOUT", "120"))
def setting(provider: str, suffix: str, fallback: str = "") -> str:
"""Ustawienie dostawcy: <DOSTAWCA>_<SUFIKS> → LLM_<SUFIKS> → wbudowana domyślna.
Wspólne `LLM_*` stosuje się WYŁĄCZNIE do dostawcy domyślnego — inaczej adres
lokalnego modelu przejąłby żądania do chmury (i odwrotnie).
"""
specific = os.getenv(f"{provider.upper()}_{suffix}")
if specific:
return specific
if provider == default_provider_name():
generic = os.getenv(f"LLM_{suffix}")
if generic:
return generic
return fallback
def resolve_model(name: str | None = None, model: str | None = None) -> tuple[str, str]:
"""(dostawca, model) BEZ budowania dostawcy — czyli bez wymogu klucza API.
Rozmiar budżetu promptu zależy tylko od okna kontekstu modelu, więc nie może
zależeć od tego, czy klucz jest już skonfigurowany.
"""
provider = (name or default_provider_name()).lower()
if provider not in PROVIDERS:
provider = default_provider_name()
chosen = (model or "").strip() or setting(provider, "MODEL", _DEFAULT_MODEL[provider])
return provider, chosen
def build_provider(name: str | None = None, model: str | None = None) -> LLMProvider:
"""Dostawca modelu. `model` z żądania wygrywa nad konfiguracją — użytkownik
wybiera model w UI, a konfiguracja podaje tylko wartość domyślną."""
name = (name or default_provider_name()).lower()
if name not in PROVIDERS:
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
model = (model or "").strip() or setting(name, "MODEL", _DEFAULT_MODEL[name])
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
api_key = setting(name, "API_KEY")
if name in (OPENAI, ANTHROPIC) and not api_key:
raise LLMError(
f"Brak klucza dla dostawcy {name} — ustaw {name.upper()}_API_KEY "
f"(z sekretu). Model lokalny klucza nie wymaga."
)
if name == ANTHROPIC:
return AnthropicProvider(base_url, model, api_key, timeout())
if name == OPENAI:
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
leaves_lan=True)
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
return ChatCompletionsProvider(LOCAL, base_url, model, api_key, timeout(),
leaves_lan=False)
+133
View File
@@ -0,0 +1,133 @@
"""Okna kontekstu modeli i planowanie budżetu tokenów (LOG-30/31).
Po co to istnieje: horoskop MA powstać niezależnie od objętości promptu. Żeby to
zagwarantować, trzeba wiedzieć dwie rzeczy o każdym modelu — ile zmieści na
wejściu (okno kontekstu) i ile maksymalnie wypisze na wyjściu. Bez tego łatwo
wysłać prompt, który wypełnia całe okno i **nie zostawia miejsca na odpowiedź** —
model kończy wtedy na `max_tokens` z pustą albo uciętą treścią.
Zasada naczelna: **zawsze rezerwuj miejsce na odpowiedź.** Budżet promptu liczy
się jako `okno_kontekstu zarezerwowane_wyjście margines`, nigdy odwrotnie.
Wartości są zaszyte jako rozsądne domyślne i nadpisywalne środowiskiem
(`<DOSTAWCA>_CONTEXT_WINDOW`, `<DOSTAWCA>_MAX_OUTPUT`) — modele wychodzą szybciej,
niż aktualizuje się ten plik.
"""
from __future__ import annotations
import os
# model -> (okno kontekstu, maksymalne wyjście) w tokenach
_MODEL_LIMITS: dict[str, tuple[int, int]] = {
# Anthropic
"claude-opus-4-8": (1_000_000, 128_000),
"claude-opus-4-7": (1_000_000, 128_000),
"claude-opus-4-6": (1_000_000, 128_000),
"claude-sonnet-5": (1_000_000, 128_000),
"claude-sonnet-4-6": (1_000_000, 128_000),
"claude-fable-5": (1_000_000, 128_000),
"claude-haiku-4-5": (200_000, 64_000),
# OpenAI
"gpt-4o": (128_000, 16_384),
"gpt-4o-mini": (128_000, 16_384),
"gpt-4.1": (1_000_000, 32_768),
"gpt-4.1-mini": (1_000_000, 32_768),
# lokalne (Ollama/vLLM) — zwykle małe okno, dlatego ostrożna domyślna
"llama3.1": (8_192, 4_096),
"llama3.2": (8_192, 4_096),
"qwen2.5": (32_768, 8_192),
"mistral": (32_768, 8_192),
}
# gdy modelu nie ma w tabeli — zachowawczo, żeby nie obiecywać nieistniejącego okna
_FALLBACK: dict[str, tuple[int, int]] = {
"anthropic": (200_000, 32_000),
"openai": (128_000, 16_384),
"local": (8_192, 4_096),
}
# ile tokenów zostawiamy jako bufor na narzut protokołu i niedokładność liczenia
SAFETY_MARGIN = 2_000
# poniżej tylu tokenów wyjścia nie ma sensu wołać modelu — nie zmieści horoskopu
MIN_OUTPUT = 1_500
# powyżej tylu tokenów promptu ostrzegamy użytkownika (nadal pozwalając wysłać)
WARN_PROMPT_TOKENS = 90_000
def _env_int(provider: str, suffix: str) -> int | None:
raw = os.getenv(f"{provider.upper()}_{suffix}")
if not raw:
return None
try:
value = int(raw)
except ValueError:
return None
return value if value > 0 else None
def limits_for(provider: str, model: str) -> tuple[int, int]:
"""(okno kontekstu, maksymalne wyjście) dla modelu — z nadpisaniem z ENV.
Dopasowanie po prefiksie, bo nazwy modeli lokalnych niosą tag (`llama3.1:8b`).
"""
env_ctx = _env_int(provider, "CONTEXT_WINDOW")
env_out = _env_int(provider, "MAX_OUTPUT")
key = (model or "").strip().lower()
known: tuple[int, int] | None = _MODEL_LIMITS.get(key)
if known is None:
for name, pair in _MODEL_LIMITS.items():
if key.startswith(name):
known = pair
break
if known is None:
known = _FALLBACK.get(provider, _FALLBACK["local"])
return (env_ctx or known[0], env_out or known[1])
def plan(provider: str, model: str, prompt_tokens: int,
want_output: int | None = None) -> dict:
"""Ile tokenów wyjścia zamówić dla promptu tej wielkości.
Zwraca plan z jawną diagnostyką — UI ma z czego zbudować ostrzeżenie, a błąd
ma czym wytłumaczyć, dlaczego się nie udało.
"""
context_window, model_max_output = limits_for(provider, model)
room = context_window - prompt_tokens - SAFETY_MARGIN
target = want_output or model_max_output
max_output = max(0, min(model_max_output, target, room))
warnings: list[str] = []
if prompt_tokens > WARN_PROMPT_TOKENS:
warnings.append(
f"Prompt ma ~{prompt_tokens} tokenów — to dużo. Zapytanie zostanie wysłane, "
f"ale potrwa dłużej i będzie odpowiednio kosztowne."
)
if max_output < MIN_OUTPUT:
warnings.append(
f"Po zmieszczeniu promptu zostaje tylko {max_output} tokenów na odpowiedź "
f"(minimum {MIN_OUTPUT}). Zmniejsz budżet promptu albo wybierz model "
f"z większym oknem kontekstu."
)
return {
"provider": provider,
"model": model,
"context_window": context_window,
"model_max_output": model_max_output,
"prompt_tokens": prompt_tokens,
"max_output": max_output,
"fits": max_output >= MIN_OUTPUT,
"warnings": warnings,
}
def prompt_token_budget(provider: str, model: str, reserve_output: int | None = None) -> int:
"""Ile tokenów promptu wolno wysłać, ZAWSZE zostawiając miejsce na odpowiedź.
To jest podstawa opcji „maksymalny kontekst modelu" w UI.
"""
context_window, model_max_output = limits_for(provider, model)
reserve = reserve_output or model_max_output
return max(0, context_window - reserve - SAFETY_MARGIN)
+306
View File
@@ -0,0 +1,306 @@
"""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) -> Completion:
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))
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
_merge_usage(usage, turn_usage)
remaining -= budget
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))
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",
}
+304 -2
View File
@@ -12,12 +12,17 @@ import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from app import link_crypto, security
from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
from app.service import QueryService
app = FastAPI(title="astrololo · warstwa logiczna")
service = QueryService()
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
link_crypto.install(app, link_crypto.ENV_PRESENTATION_LOGIC, "logiczna")
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
_engine = None
@@ -38,6 +43,8 @@ class PositionsRequest(BaseModel):
lon: float = 0.0
objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@app.post("/api/query", response_model=QueryResponse)
@@ -50,13 +57,25 @@ def query(req: QueryRequest) -> QueryResponse:
@app.post("/chart/positions")
def chart_positions(req: PositionsRequest) -> dict:
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05), aktywnym silnikiem."""
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
opcjonalnie stacje planet (LOG-03, stations=true)."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
return build_chart(engine, moment, req.house_system)
try:
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if req.stations:
from app.engine.stations import find_stations
for p in chart["positions"]:
st = find_stations(engine, moment, p["name"])
if st:
p["stations"] = st
return chart
@app.post("/chart/compare")
@@ -107,6 +126,289 @@ def chart_report(req: ReportRequest) -> dict:
return {"engine": engine.name, **report}
class PromptRequest(BaseModel):
"""Wejście generatora promptu (LOG-29/30)."""
profile: str = "natal" # natal | period
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
budget: str = "medium" # concise | medium | extensive | huge | max
limit: int = 5000
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
model: str | None = None
# tylko dla profilu period:
from_date: str | None = None
to_date: str | None = None
techniques: list[str] | None = None
@app.post("/chart/prompt")
def chart_prompt(req: PromptRequest) -> dict:
"""Gotowy prompt do LLM z naszych wyliczeń (LOG-29) z budżetowaniem (LOG-30).
profile=natal → horoskop urodzeniowy (ekran Interpretacje)
profile=period → horoskop na wybrany okres (ekran Kalendarz)
Nie woła żadnego modelu — zwraca sam prompt i statystyki redukcji, żeby dało się
go obejrzeć i skopiować. Wysyłkę do modelu doda LOG-31.
"""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.prompt import CHARS_PER_TOKEN, MAX_BUDGET, build_natal_prompt, build_period_prompt
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
chart = build_chart(engine, moment)
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
data_error = None
# Budżet „maksymalny kontekst modelu": limit znaków liczymy z okna kontekstu
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
budget_chars = None
if req.budget == MAX_BUDGET:
from app.llm.factory import resolve_model
from app.llm.limits import prompt_token_budget
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
# więc nie może wymagać skonfigurowanego klucza API
provider_name, model_name = resolve_model(req.provider, req.model)
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
try:
if req.profile == "natal":
from app.significators import build_report
report: dict = {"objects": []}
try:
report = build_report(
chart["positions"], DataClient(),
aspects=chart.get("aspects"), per_object_limit=req.limit,
)
except httpx.HTTPError as e:
data_error = f"Warstwa danych niedostępna: {e}"
out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
elif req.profile == "period":
if not (req.from_date and req.to_date):
raise HTTPException(422, "profile=period wymaga from_date i to_date")
from app.engine import houses as H
from app.engine.timeline import build_timeline
from app.significators import interpret_events
ramc, eps = engine.sidereal(moment)
points = {"Asc": H.compute_asc(ramc, eps, moment.lat), "MC": H.compute_mc(ramc, eps)}
for p in engine.positions(moment):
points[p.name] = p.longitude
events = build_timeline(engine, moment, points, req.from_date, req.to_date,
req.techniques)
try:
interpret_events(events, DataClient())
except httpx.HTTPError as e:
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
out = build_period_prompt(chart, events, req.from_date, req.to_date,
req.budget, label, budget_chars)
else:
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
except ValueError as e: # nieznany budżet
raise HTTPException(422, str(e))
out["engine"] = engine.name
if data_error:
out["data_error"] = data_error
return out
class HoroscopeRequest(PromptRequest):
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
max_tokens: int | None = None
@app.post("/chart/horoscope")
def chart_horoscope(req: HoroscopeRequest) -> dict:
"""Napisany horoskop (LOG-31): prompt z LOG-29/30 → model → gotowy tekst.
Zwraca TAKŻE użyty prompt — również gdy wywołanie modelu padnie — żeby dało się
go obejrzeć i użyć ręcznie. `leaves_lan` mówi, czy treść baz opuściła naszą sieć
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
"""
from app.llm.base import LLMError
from app.llm.factory import build_provider
from app.llm.limits import plan
out = chart_prompt(req) # ten sam prompt co w podglądzie
try:
provider = build_provider(req.provider, req.model)
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
# w oknie kontekstu w ogóle zmieści się miejsce na horoskop.
prompt_tokens = provider.count_tokens(out["prompt"])
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
out["token_plan"] = budget
if budget["warnings"]:
out["warnings"] = budget["warnings"]
if not budget["fits"]:
out["llm_error"] = " ".join(budget["warnings"])
return out
result = provider.generate(out["prompt"], budget["max_output"])
except LLMError as e:
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
out["llm_error"] = str(e)
return out
out.update(
horoscope=result.text,
provider=result.provider,
model=result.model,
leaves_lan=result.leaves_lan,
usage=result.usage,
)
return out
@app.get("/llm/models")
def llm_models() -> dict:
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
dowolny identyfikator, do którego konto ma dostęp.
"""
from app.llm.catalog import catalog
from app.llm.factory import _DEFAULT_MODEL
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
@app.get("/llm/health")
def llm_health(provider: str | None = None) -> dict:
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
from app.llm.base import LLMError
from app.llm.factory import build_provider
try:
return build_provider(provider).health()
except LLMError as e:
return {"status": f"blad konfiguracji: {e}"}
class ProfectionsRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
start_age: int = 0
count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy
@app.post("/chart/profections")
def chart_profections(req: ProfectionsRequest) -> dict:
"""Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo)."""
from app.engine import houses as H
from app.engine.models import ChartMoment
from app.engine.profections import profection_rows
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
points = {
"Asc": H.compute_asc(ramc, eps, natal.lat),
"MC": H.compute_mc(ramc, eps),
}
for p in engine.positions(natal, ["Sun", "Moon"]):
points[p.name] = p.longitude
rows = profection_rows(points, req.when_utc, req.start_age, req.count)
return {"engine": engine.name, "rows": rows}
class ReturnRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
kind: str = "solar" # solar | lunar
around: datetime | None = None # data, wokół której szukać powrotu
@app.post("/chart/return")
def chart_return(req: ReturnRequest) -> dict:
"""Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.engine.returns import find_return
if req.kind not in ("solar", "lunar"):
raise HTTPException(status_code=422, detail="kind: solar albo lunar")
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
around = req.around or req.when_utc
hit = find_return(engine, req.kind, natal, around)
if hit is None:
raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu")
chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon))
return {"engine": engine.name, "kind": req.kind,
"return_utc": hit.isoformat(), **chart}
class FirdariaRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
@app.post("/chart/firdaria")
def chart_firdaria(req: FirdariaRequest) -> dict:
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
from app.engine import houses as H
from app.engine.firdaria import firdaria
from app.engine.models import ChartMoment
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
sun = engine.positions(natal, ["Sun"])[0].longitude
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
class TimelineRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
from_date: str # zakres: YYYY-MM-DD
to_date: str
techniques: list[str] | None = None # profection | solar_return | solar_arc
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
@app.post("/chart/timeline")
def chart_timeline(req: TimelineRequest) -> dict:
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
"""
from app.engine import houses as H
from app.engine.models import ChartMoment
from app.engine.timeline import build_timeline
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
for p in engine.positions(natal):
points[p.name] = p.longitude
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
if req.interpret:
from app.significators import interpret_events
try:
interpret_events(events, DataClient())
except httpx.HTTPError as e:
out["data_error"] = f"Warstwa danych niedostępna: {e}"
out.update(count=len(events), events=events)
return out
@app.get("/health")
def health() -> dict:
info = {"status": "ok", "layer": "logic"}
+352
View File
@@ -0,0 +1,352 @@
"""Generator promptów do LLM (LOG-29) + budżetowanie rozmiaru (LOG-30).
Składa z naszych wyliczeń gotowe, profesjonalne polecenie po polsku:
- profil **natal** — horoskop urodzeniowy (na bazie /chart/report),
- profil **period** — horoskop na wybrany okres (na bazie /chart/timeline).
Zasada naczelna promptu: model ma pisać WYŁĄCZNIE na podstawie dostarczonych danych
i przy każdej tezie wskazać konkretny sygnifikator, z którego ona wynika. To odróżnia
wynik od ogólnikowej wróżby i pozwala go zweryfikować.
Budżetowanie (LOG-30) — kolejność redukcji:
1. deduplikacja (ten sam sygnifikator ORAZ ten sam opis),
2. grupowanie identycznych opisów z licznikiem wystąpień,
3. sortowanie malejąco wg punktacji siły (LOG-21),
4. obcięcie ogona do budżetu — jednostką obcięcia jest CAŁE wskazanie,
5. skracanie nadmiernie długich opisów (z jawnym oznaczeniem).
Zawsze raportujemy, ile wskazań weszło i ile pominięto — użytkownik ma wiedzieć,
czego brakuje, i móc zwiększyć budżet.
Generowanie jest deterministyczne: ten sam horoskop + ten sam budżet = ten sam prompt.
"""
from __future__ import annotations
import re
# budżety w znakach (całego promptu); dobrane pod wklejanie do ChatGPT/Claude
BUDGETS: dict[str, int] = {
"concise": 4_000,
"medium": 12_000,
"extensive": 30_000,
"huge": 120_000,
# „maksymalny kontekst modelu" — wyliczany dynamicznie z okna kontekstu
# wybranego modelu, ZAWSZE po odjęciu miejsca zarezerwowanego na odpowiedź.
# Wartość poniżej jest tylko zapasem, gdy limity modelu są nieznane.
"max": 400_000,
}
DEFAULT_BUDGET = "medium"
MAX_BUDGET = "max"
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI
DISCLAIMER = (
"Zakończ krótką notą: treść jest interpretacją astrologiczną i nie stanowi porady "
"medycznej, prawnej ani finansowej."
)
def _norm(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
def est_tokens(text: str) -> int:
return int(len(text) / CHARS_PER_TOKEN)
def _shorten(text: str, limit: int = MAX_EFFECT_CHARS) -> tuple[str, bool]:
text = (text or "").strip()
if len(text) <= limit:
return text, False
return text[:limit].rstrip() + " […skrócono]", True
# ----------------------------------------------------------------- wskazania
def natal_indications(report: dict) -> list[dict]:
"""Płaska lista wskazań z raportu natalnego: obiekt + faseta + opis + waga."""
out: list[dict] = []
for obj in report.get("objects") or []:
name = obj.get("object")
for facet in obj.get("facets") or []:
score = float(facet.get("score") or 0.0)
for s in facet.get("samples") or []:
out.append({
"context": f"{name}{facet.get('label')}",
"sort_key": (name or "", str(facet.get("label") or "")),
"score": score,
"significator": s.get("expanded") or s.get("significator") or "",
"effect": s.get("effect") or "",
})
return out
def period_indications(events: list[dict]) -> list[dict]:
"""Wskazania z osi czasu — waga rośnie z liczbą trafień w bazie."""
out: list[dict] = []
for ev in events or []:
head = f"{ev.get('technique')} · {ev.get('significator')} · {ev.get('exact')}"
total = float(ev.get("interpretations_count") or 0)
for s in ev.get("interpretations") or []:
out.append({
"context": head,
"sort_key": (str(ev.get("exact") or ""), str(ev.get("technique") or "")),
"score": total,
"significator": s.get("expanded") or s.get("significator") or "",
"effect": s.get("effect") or "",
"date": ev.get("exact"),
"start": ev.get("start"),
"end": ev.get("end"),
"technique": ev.get("technique"),
})
return out
def reduce_indications(items: list[dict], budget_chars: int) -> tuple[list[dict], dict]:
"""Dedup → grupowanie → sortowanie wg wagi → obcięcie do budżetu.
Zwraca (wybrane wskazania, statystyki). Jednostką obcięcia jest całe wskazanie.
"""
# 1+2: dedup i grupowanie identycznych (kontekst, opis) z licznikiem
grouped: dict[tuple[str, str], dict] = {}
order: list[tuple[str, str]] = []
for it in items:
key = (_norm(it["context"]), _norm(it["effect"]))
g = grouped.get(key)
if g is None:
g = {**it, "count": 0, "significators": []}
grouped[key] = g
order.append(key)
g["count"] += 1
if it["significator"] not in g["significators"]:
g["significators"].append(it["significator"])
merged = [grouped[k] for k in order]
deduped = len(items) - len(merged)
# 3: sortowanie malejąco wg wagi; remis rozstrzygany deterministycznie
merged.sort(key=lambda g: (-g["score"], -g["count"], g["sort_key"], g["significator"]))
# 5 (przed obcięciem, bo wpływa na rozmiar): skracanie długich opisów
shortened = 0
for g in merged:
g["effect"], was = _shorten(g["effect"])
shortened += 1 if was else 0
# 4: obcięcie ogona do budżetu — cała pozycja albo nic
chosen: list[dict] = []
used = 0
for g in merged:
cost = len(_render_indication(g)) + 1
if used + cost > budget_chars and chosen:
break
chosen.append(g)
used += cost
omitted = len(merged) - len(chosen)
stats = {
"source_rows": len(items),
"after_grouping": len(merged),
"deduplicated": deduped,
"included": len(chosen),
"omitted": omitted,
"shortened": shortened,
"min_score_included": round(chosen[-1]["score"], 3) if chosen else None,
"max_score_omitted": round(merged[len(chosen)]["score"], 3) if omitted else None,
}
return chosen, stats
def _render_indication(g: dict) -> str:
times = f" (×{g['count']})" if g.get("count", 1) > 1 else ""
sig = g.get("significator") or ""
return f"- {sig}{g.get('effect')}{times}"
def _render_grouped(chosen: list[dict]) -> str:
"""Renderuje wskazania pogrupowane po kontekście, zachowując kolejność wagi."""
blocks: list[str] = []
seen: dict[str, list[dict]] = {}
order: list[str] = []
for g in chosen:
ctx = g["context"]
if ctx not in seen:
seen[ctx] = []
order.append(ctx)
seen[ctx].append(g)
for ctx in order:
rows = seen[ctx]
blocks.append(f"## {ctx} [waga {rows[0]['score']:.2f}]")
blocks.extend(_render_indication(g) for g in rows)
blocks.append("")
return "\n".join(blocks).rstrip()
# ------------------------------------------------------------- sekcje danych
def _chart_section(chart: dict, moment_label: str | None) -> str:
lines: list[str] = ["# DANE HOROSKOPU"]
meta = []
if moment_label:
meta.append(f"moment: {moment_label}")
if chart.get("zodiac"):
z = chart["zodiac"]
if chart.get("ayanamsha") is not None:
z += f" (ayanamsa {chart['ayanamsha']:.4f}°)"
meta.append(f"zodiak: {z}")
if chart.get("house_system"):
meta.append(f"system domów: {chart['house_system']}")
if chart.get("sect"):
meta.append(f"sekta: {'dzienna' if chart['sect'] == 'day' else 'nocna'}")
if meta:
lines.append(" · ".join(meta))
if chart.get("positions"):
lines.append("\n## Pozycje")
for p in chart["positions"]:
house = f"dom {p['house']}" if p.get("house") else ""
lines.append(f"{p['name']:<12} {p.get('in_sign',''):<16} {house:<8} {p.get('direction','')}")
angles = chart.get("angles") or {}
if angles:
lines.append("\n## Osie")
for key in ("Asc", "MC", "Dsc", "IC"):
a = angles.get(key)
if a:
lines.append(f"{a['name']:<5} {a.get('in_sign','')}")
if chart.get("lots"):
lines.append("\n## Lots (punkty arabskie)")
for lot in chart["lots"]:
lines.append(
f"{lot['name']:<10} {lot.get('in_sign',''):<16} dom {lot.get('house','')}"
f" ({lot.get('formula','')})"
)
if chart.get("aspects"):
lines.append("\n## Aspekty (orb; A = aplikacyjny, S = separacyjny)")
for a in chart["aspects"]:
mark = a.get("as") or ""
lines.append(
f"{a['obj1']} {a['aspect']} {a['obj2']} orb {a.get('orb', 0):.2f}° {mark}"
)
return "\n".join(lines)
def _indications_section(chosen: list[dict], stats: dict) -> str:
if not chosen:
return (
"# WSKAZANIA Z BAZ\n"
"(brak trafień w bazach dla tego horoskopu — oprzyj interpretację wyłącznie "
"na danych horoskopu powyżej)"
)
head = [
"# WSKAZANIA Z BAZ INTERPRETACYJNYCH",
"Wskazania dopasowane do tego horoskopu, uporządkowane od najsilniejszych.",
"Zapis: `- sygnifikator → opis (×ile razy wystąpiło w bazach)`.",
]
if stats.get("omitted"):
head.append(
f"UWAGA: pokazano {stats['included']} najsilniejszych wskazań, pominięto "
f"{stats['omitted']} słabszych (limit długości)."
)
return "\n".join(head) + "\n\n" + _render_grouped(chosen)
# ------------------------------------------------------------------ prompty
_NATAL_TASK = """# ZADANIE
Jesteś doświadczonym astrologiem. Napisz profesjonalną interpretację HOROSKOPU
URODZENIOWEGO po polsku, opierając się wyłącznie na danych podanych niżej."""
_PERIOD_TASK = """# ZADANIE
Jesteś doświadczonym astrologiem. Napisz profesjonalną prognozę astrologiczną
NA WYBRANY OKRES po polsku, opierając się wyłącznie na danych podanych niżej."""
_NATAL_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
1. Struktura: (a) portret ogólny, (b) temperament i sekta, (c) obszary życia według domów,
(d) napięcia i wyzwania, (e) zasoby i mocne strony, (f) zwięzłe podsumowanie.
2. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie, np. „(Moon in 12th house)".
Teza bez wskazania jest niedopuszczalna.
3. Nie dodawaj twierdzeń, których nie da się wywieść z powyższych danych. Nie zmyślaj
pozycji, aspektów ani wskazań; nie korzystaj z wiedzy spoza tego promptu.
4. Gdy wskazania są sprzeczne, powiedz to wprost i wskaż obie strony, zamiast wybierać jedną.
5. Wagę wskazania traktuj jako siłę świadectwa — mocniejsze mają pierwszeństwo w syntezie.
6. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
_PERIOD_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
1. Uporządkuj prognozę CHRONOLOGICZNIE; przy każdym okresie podaj daty (start / dokładna / koniec).
2. Dla każdej daty napisz, która technika ją wyznacza (profekcja, solariusz, dyrekcja solar-arc,
Firdaria) i co z niej wynika.
3. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie. Teza bez wskazania jest
niedopuszczalna.
4. Nie zmyślaj dat ani zdarzeń spoza podanych. Nie korzystaj z wiedzy spoza tego promptu.
5. Rozróżniaj okresy o mocnym świadectwie (wysoka waga, kilka technik zbieżnych w czasie)
od słabych — i powiedz wprost, które są które.
6. Na końcu dodaj krótkie zestawienie: najważniejsze okresy w kolejności ważności.
7. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str:
return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"])
def _budget_chars(budget: str, budget_chars: int | None = None) -> int:
"""Limit znaków promptu. `budget_chars` nadpisuje tabelę — używane dla opcji
„maksymalny kontekst modelu", gdzie limit zależy od wybranego modelu i musi
być policzony po odjęciu miejsca zarezerwowanego na odpowiedź."""
if budget not in BUDGETS:
raise ValueError(
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
)
if budget_chars and budget_chars > 0:
return budget_chars
return BUDGETS[budget]
def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) -> dict:
stats = {
**stats,
"profile": profile,
"budget": budget,
"limit_chars": limit,
"chars": len(prompt),
"est_tokens": est_tokens(prompt),
}
return {"profile": profile, "prompt": prompt, "stats": stats}
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
moment_label: str | None = None,
budget_chars: int | None = None) -> dict:
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
limit = _budget_chars(budget, budget_chars)
chart_sec = _chart_section(chart, moment_label)
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
prompt = _assemble(_NATAL_TASK, chart_sec, _indications_section(chosen, stats), _NATAL_OUTPUT)
return _finish(prompt, budget, limit, stats, "natal")
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
budget: str = DEFAULT_BUDGET, moment_label: str | None = None,
budget_chars: int | None = None) -> dict:
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
limit = _budget_chars(budget, budget_chars)
chart_sec = _chart_section(chart, moment_label)
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date}{to_date}**."
ev_lines = ["# OŚ CZASU (techniki predykcyjne)",
"Zapis: technika · sygnifikator · start → dokładna → koniec."]
for ev in events or []:
ev_lines.append(
f"- {ev.get('technique')} · {ev.get('significator')} · "
f"{ev.get('start')}{ev.get('exact')}{ev.get('end')}"
)
events_sec = "\n".join(ev_lines)
fixed = len(task) + len(chart_sec) + len(events_sec) + len(_PERIOD_OUTPUT) + len(DISCLAIMER) + 200
chosen, stats = reduce_indications(period_indications(events), max(limit - fixed, 500))
body = events_sec + "\n\n" + _indications_section(chosen, stats)
prompt = _assemble(task, chart_sec, body, _PERIOD_OUTPUT)
stats["events"] = len(events or [])
return _finish(prompt, budget, limit, stats, "period")
+6 -1
View File
@@ -18,6 +18,8 @@ ASPECT_WEIGHT = {
"conjunction": 1.0, "opposition": 0.95, "square": 0.85,
"trine": 0.85, "sextile": 0.65,
}
# aspekt aplikacyjny (A) jest silniejszy niż separacyjny (S) — notes3
APPLYING_BONUS = 1.15
def score_facet(facet: dict) -> float:
@@ -28,5 +30,8 @@ def score_facet(facet: dict) -> float:
orb = facet.get("orb")
allowed = facet.get("allowed") or 10.0
tight = max(0.0, 1.0 - orb / allowed) if orb is not None and allowed else 0.0
return round(base * weight * (1.0 + tight), 2)
score = base * weight * (1.0 + tight)
if facet.get("applying"):
score *= APPLYING_BONUS
return round(score, 2)
return round(base, 2)
+50
View File
@@ -0,0 +1,50 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w
prezentacji nie wystarczy — bez tego kontrolera wystarczyłoby uderzyć w logikę
z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
+65 -1
View File
@@ -20,6 +20,10 @@ from app.scoring import score_facet
PLANET_ABBR = {
"Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma",
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
"North Node": "NN", "South Node": "SN", "Lilith": "Lilith",
# Lots — w bazie Fortuna występuje jako [PF (Part of Fortune)
"Fortune": "PF", "Spirit": "Spirit",
}
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
@@ -167,10 +171,12 @@ def build_report(
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
continue
as_suffix = f" ({asp['as']})" if asp.get("as") else ""
facets.append({
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}",
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}{as_suffix}",
"token": f"{asp_tok} + {other_tok}",
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
"applying": asp.get("applying"),
"count": len(asp_samples), "samples": asp_samples,
})
@@ -191,3 +197,61 @@ def build_report(
"facets": facets,
})
return {"provider": provider, "objects": items}
def _event_tokens(event: dict) -> list[str]:
"""Tokeny bazy dla zdarzenia osi czasu (spięcie 1B→2B). Pierwszy = planeta."""
from app.engine.aspects import DB_TOKEN
if event.get("technique") == "solar_arc":
p = PLANET_ABBR.get(event.get("directed"))
a = DB_TOKEN.get(event.get("aspect"))
q = PLANET_ABBR.get(event.get("target")) # None dla Asc/MC (nie ma tokenu)
toks = []
if p:
toks.append("[" + p)
if a:
toks.append(a)
if q:
toks.append("[" + q)
return toks if p else []
if event.get("technique") == "profection":
lord = PLANET_ABBR.get(event.get("lord"))
sign = SIGN_TO_ABBR.get(event.get("sign"))
toks = []
if lord:
toks.append("[" + lord)
if sign:
toks.append("[" + sign)
return toks if lord else []
if event.get("technique") == "firdaria":
major = PLANET_ABBR.get(event.get("fd_major"))
sub = PLANET_ABBR.get(event.get("fd_sub"))
toks = []
if major:
toks.append("[" + major)
if sub:
toks.append("[" + sub)
return toks if major else []
return []
def interpret_events(events: list[dict], data: DataSource, limit: int = 4,
per_object_limit: int = 5000) -> list[dict]:
"""Dopina interpretacje z bazy do zdarzeń osi czasu (predykcyjne 1B → 2B).
Wyszukuje po tokenie planety zdarzenia i zawęża do wszystkich tokenów
(aspekt/druga planeta lub znak), z odsiewaniem szumu i duplikatów.
"""
for ev in events:
tokens = _event_tokens(ev)
if not tokens:
continue
raw = data.search(
key="significator", value=tokens[0], exact=False, limit=per_object_limit,
fields=["significator", "actioneffect", "topicresult", "bodypart"],
)
samples = _facet_samples(raw.get("rows", []), tokens)
ev["interpretations"] = samples[:limit]
ev["interpretations_count"] = len(samples)
return events
+2
View File
@@ -4,3 +4,5 @@ httpx>=0.28
pydantic>=2.10
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
skyfield>=1.49
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
+5
View File
@@ -7,6 +7,7 @@ służą jako wyrocznia (LOG-25).
from __future__ import annotations
import datetime as dt
import os
import pytest
@@ -28,4 +29,8 @@ def own_engine():
try:
return SkyfieldEngine()
except Exception as e: # brak efemeryd / brak sieci
# Lokalnie pomijamy (dev bez pobranego jądra), ale w CI to musi być błąd —
# inaczej testy referencyjne (walidacja względem astro.com) cicho znikają.
if os.getenv("CI"):
pytest.fail(f"CI: silnik efemeryd niedostępny — testy referencyjne muszą działać: {e}")
pytest.skip(f"Nie można wczytać efemeryd: {e}")
+78 -1
View File
@@ -1,5 +1,5 @@
"""Testy aspektów (LOG-06) — czysta matematyka."""
from app.engine.aspects import find_aspects, separation
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
def test_separation_wraparound():
@@ -33,3 +33,80 @@ def test_luminary_bonus_widens_orb():
def test_one_aspect_per_pair():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert len(find_aspects(pos)) == 1
def test_applying_when_faster_body_catches_up():
# Księżyc 5° za Słońcem, szybszy -> koniunkcja aplikacyjna
pos = [
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
{"name": "Moon", "decimal": 35.0, "speed": 13.0},
]
a = find_aspects(pos)[0]
assert a["applying"] is True and a["as"] == "A"
def test_separating_when_moving_apart():
# Księżyc 5° przed Słońcem i szybszy -> koniunkcja separacyjna
pos = [
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
{"name": "Moon", "decimal": 45.0, "speed": 13.0},
]
a = find_aspects(pos)[0]
assert a["applying"] is False and a["as"] == "S"
def test_rigid_pair_nodes_not_reported():
# SN = NN + 180° z definicji — trywialna opozycja, nie aspekt
pos = [
{"name": "North Node", "decimal": 42.0, "speed": -0.053},
{"name": "South Node", "decimal": 222.0, "speed": -0.053},
{"name": "Sun", "decimal": 42.5, "speed": 0.96},
]
pairs = {frozenset((a["obj1"], a["obj2"])) for a in find_aspects(pos)}
assert frozenset({"North Node", "South Node"}) not in pairs
# aspekty węzłów do innych obiektów zostają nietknięte
assert frozenset({"Sun", "North Node"}) in pairs
assert frozenset({"Sun", "South Node"}) in pairs
def test_no_rigid_pairs_in_real_chart(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
found = [a for a in chart["aspects"] if frozenset((a["obj1"], a["obj2"])) in RIGID_PAIRS]
assert not found, f"trywialne aspekty par sztywnych w horoskopie: {found}"
def test_no_as_flag_without_speeds():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert "as" not in find_aspects(pos)[0]
# Referencja A/S z notes3 (astro-seek) dla horoskopu 30.04.1984 07:35 UT, Warszawa.
REFERENCE_AS = {
("Sun", "Moon", "conjunction"): "A",
("Sun", "Jupiter", "trine"): "A",
("Sun", "Saturn", "opposition"): "A",
("Sun", "Neptune", "trine"): "S",
("Sun", "Pluto", "opposition"): "S",
("Moon", "Mercury", "conjunction"): "S",
("Moon", "Venus", "conjunction"): "S",
("Moon", "Neptune", "trine"): "A",
("Moon", "Pluto", "opposition"): "S",
("Mercury", "Venus", "conjunction"): "S",
("Mercury", "Neptune", "trine"): "S",
("Mercury", "Pluto", "opposition"): "S",
("Venus", "Neptune", "trine"): "A",
("Venus", "Pluto", "opposition"): "A",
("Jupiter", "Saturn", "sextile"): "A",
("Neptune", "Pluto", "sextile"): "S",
}
def test_applying_separating_matches_astroseek_reference(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
got = {(a["obj1"], a["obj2"], a["aspect"]): a.get("as") for a in chart["aspects"]}
mismatches = {k: (got.get(k), v) for k, v in REFERENCE_AS.items() if got.get(k) != v}
assert not mismatches, f"rozbieżności A/S vs astro-seek: {mismatches}"
+58
View File
@@ -0,0 +1,58 @@
"""Firdaria (LOG-11) — sekta, kolejność, długości okresów, podokresy."""
import datetime as dt
from app.engine.firdaria import DAY_ORDER, NIGHT_ORDER, YEARS, firdaria, is_day_birth
from app.significators import _event_tokens
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
# horoskop referencyjny: Asc 112.18, MC 352.59, Sun 40.14 -> Day birth (notes3)
ASC, MC, SUN = 112.18, 352.59, 40.14
def test_reference_is_day_birth():
assert is_day_birth(SUN, ASC, MC) is True # notes3: "Day birth (Diurnal)"
def test_night_birth_uses_night_order():
# Słońce po stronie IC (przeciwna półkula) -> noc
assert is_day_birth((SUN + 180) % 360, ASC, MC) is False
fd = firdaria(BIRTH, (SUN + 180) % 360, ASC, MC)
assert fd["sect"] == "night" and fd["order"] == NIGHT_ORDER
def test_diurnal_sequence_and_totals():
fd = firdaria(BIRTH, SUN, ASC, MC)
assert fd["sect"] == "day" and fd["order"] == DAY_ORDER
lords = [p["lord"] for p in fd["periods"]]
assert lords == DAY_ORDER + ["North Node", "South Node"]
assert sum(p["years"] for p in fd["periods"]) == 75 # 70 + 3 + 2
def test_planet_majors_have_seven_subperiods_summing_to_period():
fd = firdaria(BIRTH, SUN, ASC, MC)
for p in fd["periods"]:
if p["lord"] in YEARS:
assert len(p["sub"]) == 7
assert p["sub"][0]["lord"] == p["lord"] # sub zaczyna się od władcy okresu
assert p["sub"][0]["start"] == p["start"]
assert p["sub"][-1]["end"] == p["end"]
else:
assert "sub" not in p # węzły bez podokresów
def test_periods_are_contiguous():
fd = firdaria(BIRTH, SUN, ASC, MC)
for a, b in zip(fd["periods"], fd["periods"][1:]):
assert a["end"] == b["start"]
def test_age_42_is_saturn_major():
# kumulatywnie: Su10 Ve8 Me13 Mo9 -> 40; Saturn 40-51 -> wiek 42 w Saturnie
fd = firdaria(BIRTH, SUN, ASC, MC)
saturn = next(p for p in fd["periods"] if p["lord"] == "Saturn")
assert saturn["start"].startswith("2024") and saturn["end"].startswith("2035")
def test_event_tokens_firdaria():
ev = {"technique": "firdaria", "fd_major": "Saturn", "fd_sub": "Jupiter"}
assert _event_tokens(ev) == ["[Sa", "[Ju"]
+91
View File
@@ -0,0 +1,91 @@
"""Okna kontekstu i planowanie budżetu tokenów (LOG-30/31).
Naczelna zasada, której pilnują te testy: **zawsze zostaje miejsce na odpowiedź**.
Prompt nigdy nie może wypełnić całego okna kontekstu, bo wtedy model kończy na
`max_tokens` z pustą albo uciętą treścią — to był zgłoszony błąd.
"""
import pytest
from app.llm import limits
def test_known_models_have_documented_limits():
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
assert ctx == 1_000_000 and out == 128_000
ctx, out = limits.limits_for("anthropic", "claude-haiku-4-5")
assert ctx == 200_000 and out == 64_000
def test_local_model_tag_is_matched_by_prefix():
"""Modele lokalne niosą tag (`llama3.1:8b`) — dopasowanie musi to znieść."""
assert limits.limits_for("local", "llama3.1:8b") == limits.limits_for("local", "llama3.1")
def test_unknown_model_falls_back_conservatively():
ctx, out = limits.limits_for("local", "jakis-egzotyczny-model")
assert ctx == 8_192 and out == 4_096
def test_env_overrides_win(monkeypatch):
"""Modele wychodzą szybciej, niż aktualizuje się tabela."""
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "131072")
monkeypatch.setenv("LOCAL_MAX_OUTPUT", "8192")
assert limits.limits_for("local", "llama3.1:8b") == (131_072, 8_192)
def test_env_override_ignores_garbage(monkeypatch):
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "nie-liczba")
assert limits.limits_for("local", "llama3.1:8b")[0] == 8_192
# ------------------------------------------------- rezerwa miejsca na odpowiedź
def test_prompt_budget_always_reserves_room_for_answer():
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
budget = limits.prompt_token_budget("anthropic", "claude-opus-4-8")
assert budget + out + limits.SAFETY_MARGIN <= ctx
assert budget > 0
def test_small_context_model_still_leaves_room():
budget = limits.prompt_token_budget("local", "llama3.1:8b")
ctx, out = limits.limits_for("local", "llama3.1:8b")
assert budget + out + limits.SAFETY_MARGIN <= ctx
def test_plan_shrinks_output_when_prompt_is_huge():
"""Duży prompt nie może dostać pełnego okna wyjścia — musi się zmieścić."""
ctx, model_out = limits.limits_for("local", "llama3.1:8b")
p = limits.plan("local", "llama3.1:8b", prompt_tokens=6_000)
assert p["max_output"] <= ctx - 6_000 - limits.SAFETY_MARGIN
assert p["max_output"] < model_out
def test_plan_reports_not_fitting_instead_of_failing_silently():
p = limits.plan("local", "llama3.1:8b", prompt_tokens=8_000)
assert p["fits"] is False
assert any("odpowied" in w for w in p["warnings"])
def test_plan_warns_above_90k_but_still_fits():
"""Powyżej 90 tys. tokenów ostrzegamy — ale wysłanie MA być nadal możliwe."""
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=120_000)
assert p["fits"] is True, "duży prompt nadal musi dać się wysłać"
assert p["max_output"] >= limits.MIN_OUTPUT
assert any("dużo" in w for w in p["warnings"])
def test_no_warning_below_threshold():
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=10_000)
assert p["warnings"] == [] and p["fits"] is True
def test_requested_output_is_capped_by_model_maximum():
p = limits.plan("anthropic", "claude-haiku-4-5", prompt_tokens=1_000, want_output=999_999)
assert p["max_output"] == 64_000
@pytest.mark.parametrize("tokens", [0, 1_000, 50_000, 200_000, 900_000])
def test_plan_never_returns_negative_output(tokens):
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=tokens)
assert p["max_output"] >= 0
+351
View File
@@ -0,0 +1,351 @@
"""Szyfrowanie łącza między warstwami (PRE-16).
Sedno: przez to łącze płyną surowe wiersze oryginalnych baz interpretacyjnych.
Testy nie sprawdzają więc tylko, czy „coś się zaszyfrowało i odszyfrowało"
sprawdzają, czy tajnego tekstu FAKTYCZNIE NIE MA w bajtach lecących po sieci
oraz czy każda znana droga na skróty (brak szyfrowania, obcy klucz, podmieniony
bajt, przeklejenie na inny endpoint, odtworzenie po czasie) kończy się odmową.
"""
import json
import pathlib
import time
import pytest
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from starlette.testclient import TestClient
from app import link_crypto
from app.link_crypto import ENV_LOGIC_DATA, ENV_PRESENTATION_LOGIC, Link, LinkError
SECRET = "Saturn w VII domu — opis z oryginalnej bazy interpretacyjnej"
KEY_A = "11" * 32 # hex, 32 bajty
KEY_B = "22" * 32
class Payload(BaseModel):
question: str
def _app(link: Link | None) -> FastAPI:
app = FastAPI()
if link is not None:
app.add_middleware(link_crypto.LinkCryptoMiddleware, link=link, layer="testowa")
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/search")
def search(payload: Payload):
return {"echo": payload.question, "interpretation": SECRET}
@app.get("/catalog")
def catalog():
return {"models": ["a", "b"]}
@app.post("/stream")
def stream():
def lines():
for i in range(4):
yield json.dumps({"step": i, "note": SECRET}).encode() + b"\n"
return StreamingResponse(lines(), media_type="application/x-ndjson")
return app
@pytest.fixture
def link():
return Link(link_crypto.parse_key(KEY_A))
# ------------------------------------------------------------------- klucze
def test_key_accepts_hex_and_base64():
import base64
raw = bytes(range(32))
assert link_crypto.parse_key(raw.hex()) == raw
assert link_crypto.parse_key(base64.b64encode(raw).decode()) == raw
def test_key_of_wrong_length_is_rejected_loudly():
"""Krótki klucz to nie „słabsze szyfrowanie", tylko błąd konfiguracji."""
with pytest.raises(LinkError, match="32"):
link_crypto.parse_key("aabb")
def test_key_that_is_neither_hex_nor_base64_is_rejected():
with pytest.raises(LinkError, match="hex"):
link_crypto.parse_key("!!! to nie jest klucz !!!")
def test_key_from_env_is_lazy(monkeypatch):
monkeypatch.delenv(ENV_PRESENTATION_LOGIC, raising=False)
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) is None
monkeypatch.setenv(ENV_PRESENTATION_LOGIC, KEY_A)
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) == bytes.fromhex(KEY_A)
def test_directions_use_different_subkeys(link):
"""Żądanie i odpowiedź nie dzielą klucza — powtórzenie jednorazówki w jedną
stronę nie osłabia drugiej."""
stamp = link_crypto.stamp_now()
sealed = link.seal(link_crypto.REQUEST, "/search", stamp, 0, b"tajne")
with pytest.raises(LinkError):
link.open(link_crypto.RESPONSE, "/search", stamp, 0, sealed)
# --------------------------------------------------------- podstawowy obieg
def test_round_trip_delivers_plaintext_to_the_app(link):
with TestClient(_app(link)) as client:
got = link_crypto.call_json(client, "POST", "http://testserver/search",
payload={"question": "Saturn"}, link=link)
assert got["echo"] == "Saturn"
assert got["interpretation"] == SECRET
def test_get_without_body_also_works(link):
"""GET nie ma ciała, ale i tak pieczętujemy pustą kopertę — to ona dowodzi,
że pytający ma klucz, i ona wymusza zaszyfrowanie odpowiedzi."""
with TestClient(_app(link)) as client:
got = link_crypto.call_json(client, "GET", "http://testserver/catalog", link=link)
assert got == {"models": ["a", "b"]}
def test_without_key_traffic_stays_plaintext(link):
"""Dev bez sekretów ma działać jak dotąd — inaczej nikt nie odpali projektu lokalnie."""
with TestClient(_app(None)) as client:
got = link_crypto.call_json(client, "POST", "http://testserver/search",
payload={"question": "Saturn"}, link=None)
assert got["interpretation"] == SECRET
def test_health_stays_open_for_kubernetes_probes(link):
"""Sondy k8s klucza nie mają. Gdyby /health wymagał szyfrowania, literówka
w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji."""
with TestClient(_app(link)) as client:
assert client.get("http://testserver/health").json() == {"status": "ok"}
# ------------------------------------------- czy na kablu naprawdę nic nie widać
def _raw_exchange(client, link, payload):
"""Wysyła zapieczętowane żądanie i zwraca SUROWE bajty obu stron."""
stamp = link_crypto.stamp_now()
body = link_crypto.frame_out(
link.seal(link_crypto.REQUEST, "/search", stamp, 0, json.dumps(payload).encode()))
response = client.request(
"POST", "http://testserver/search", content=body,
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: stamp,
"Content-Type": link_crypto.CONTENT_TYPE})
return body, response
def test_request_bytes_do_not_contain_the_question(link):
with TestClient(_app(link)) as client:
body, _ = _raw_exchange(client, link, {"question": "Saturn w VII"})
assert b"Saturn" not in body
assert b"question" not in body
def test_response_bytes_do_not_contain_the_interpretation(link):
"""To jest właściwy powód istnienia całego modułu."""
with TestClient(_app(link)) as client:
_, response = _raw_exchange(client, link, {"question": "Saturn"})
assert response.status_code == 200
assert SECRET.encode() not in response.content
assert b"interpretation" not in response.content
assert response.headers[link_crypto.HEADER_ENC] == link_crypto.VERSION
assert response.headers["content-type"] == link_crypto.CONTENT_TYPE
# ------------------------------------------------------- drogi na skróty i ataki
def test_plaintext_request_is_refused_when_key_is_set(link):
"""Fail-closed: regresja po stronie klienta nie może oznaczać cichego
powrotu do jawnego ruchu."""
with TestClient(_app(link)) as client:
response = client.post("http://testserver/search", json={"question": "Saturn"})
assert response.status_code == 400
assert SECRET.encode() not in response.content
def test_foreign_key_cannot_read_the_link(link):
"""Klucze są osobne dla każdej pary warstw — przejęcie jednego nie otwiera drugiej."""
intruder = Link(link_crypto.parse_key(KEY_B))
with TestClient(_app(link)) as client:
stamp = link_crypto.stamp_now()
body = link_crypto.frame_out(
intruder.seal(link_crypto.REQUEST, "/search", stamp, 0, b'{"question":"x"}'))
response = client.request(
"POST", "http://testserver/search", content=body,
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: stamp,
"Content-Type": link_crypto.CONTENT_TYPE})
assert response.status_code == 400
assert SECRET.encode() not in response.content
def test_single_flipped_bit_is_rejected(link):
"""GCM uwierzytelnia, więc nie ma wariantu „odszyfrowało się, ale zmienione"."""
with TestClient(_app(link)) as client:
stamp = link_crypto.stamp_now()
sealed = bytearray(link.seal(link_crypto.REQUEST, "/search", stamp, 0,
b'{"question":"x"}'))
sealed[-1] ^= 0x01
response = client.request(
"POST", "http://testserver/search",
content=link_crypto.frame_out(bytes(sealed)),
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: stamp,
"Content-Type": link_crypto.CONTENT_TYPE})
assert response.status_code == 400
def test_frame_cannot_be_replayed_against_another_endpoint(link):
"""Ścieżka wchodzi do materiału uwierzytelnianego, więc podsłuchanej koperty
nie da się przekleić tam, gdzie odpowiedź byłaby ciekawsza."""
stamp = link_crypto.stamp_now()
sealed = link.seal(link_crypto.REQUEST, "/catalog", stamp, 0, b"")
with pytest.raises(LinkError):
link.open(link_crypto.REQUEST, "/search", stamp, 0, sealed)
def test_frames_cannot_be_reordered(link):
"""Numer ramki jest uwierzytelniony — przestawienie kolejności w strumieniu
to błąd, a nie po cichu pomieszany horoskop."""
stamp = link_crypto.stamp_now()
second = link.seal(link_crypto.RESPONSE, "/stream", stamp, 1, b"druga")
with pytest.raises(LinkError):
link.open(link_crypto.RESPONSE, "/stream", stamp, 0, second)
def test_stale_frame_is_refused(link, monkeypatch):
"""Bez okna czasowego podsłuchane żądanie dałoby się odtworzyć kiedykolwiek."""
old = f"{time.time() - link_crypto.MAX_SKEW_SECONDS - 60:.3f}"
with TestClient(_app(link)) as client:
body = link_crypto.frame_out(
link.seal(link_crypto.REQUEST, "/search", old, 0, b'{"question":"x"}'))
response = client.request(
"POST", "http://testserver/search", content=body,
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: old,
"Content-Type": link_crypto.CONTENT_TYPE})
assert response.status_code == 400
def test_truncated_stream_is_an_error_not_silent_loss(link):
stamp = link_crypto.stamp_now()
full = link_crypto.frame_out(link.seal(link_crypto.RESPONSE, "/x", stamp, 0, b"abc"))
with pytest.raises(LinkError, match="urwana"):
link.open_all(link_crypto.RESPONSE, "/x", stamp, full[:-2])
def test_absurd_frame_length_does_not_allocate(link):
"""Zadeklarowana długość pochodzi z sieci — nie wolno jej wierzyć na słowo."""
import struct
with pytest.raises(LinkError, match="rozmiar"):
list(link_crypto.frames_in(struct.pack(">I", 2 ** 31) + b"nic"))
# ---------------------------------------------------------- odpowiedź strumieniowa
def test_streaming_response_survives_encryption(link):
"""Okno postępu dostaje kolejne linie na żywo — muszą dojść po kolei
i w komplecie, mimo że każda jedzie w osobnej kopercie."""
with TestClient(_app(link)) as client:
stamp = link_crypto.stamp_now()
body = link_crypto.frame_out(link.seal(link_crypto.REQUEST, "/stream", stamp, 0, b""))
with client.stream("POST", "http://testserver/stream", content=body,
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: stamp,
"Content-Type": link_crypto.CONTENT_TYPE}) as response:
chunks = list(link_crypto.open_response_stream(response, link))
steps = [json.loads(line) for line in b"".join(chunks).splitlines()]
assert [s["step"] for s in steps] == [0, 1, 2, 3]
assert all(s["note"] == SECRET for s in steps)
def test_incremental_unframing_handles_split_frames(link):
"""Ramka potrafi rozjechać się między dwa odczyty z gniazda — składamy ją
w buforze, zamiast zakładać, że każdy kawałek to komplet."""
stamp = link_crypto.stamp_now()
stream = b"".join(link.seal_stream(link_crypto.RESPONSE, "/x", stamp,
[b"raz", b"dwa", b"trzy"]))
buffer = bytearray()
opened, seq = [], 0
for i in range(0, len(stream), 5): # ciachamy w poprzek ramek
buffer += stream[i:i + 5]
for frame in link_crypto.unframe_incremental(buffer):
opened.append(link.open(link_crypto.RESPONSE, "/x", stamp, seq, frame))
seq += 1
assert opened == [b"raz", b"dwa", b"trzy"]
assert not buffer, "bufor musi zostać pusty — inaczej gdzieś zgubiliśmy ramkę"
# ----------------------------------------------- trzy kopie muszą być identyczne
def test_all_three_services_share_the_same_module():
"""Moduł jest skopiowany do trzech niezależnych usług (nie mają wspólnej
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
„nie da się odszyfrować" — więc pilnujemy tego testem."""
root = pathlib.Path(__file__).resolve().parents[3]
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
for svc in ("presentation", "logic", "data")}
missing = [svc for svc, path in copies.items() if not path.is_file()]
assert not missing, f"brak modułu w warstwach: {missing}"
contents = {svc: path.read_bytes() for svc, path in copies.items()}
assert len(set(contents.values())) == 1, (
"kopie link_crypto.py rozjechały się między warstwami: "
+ ", ".join(f"{svc}={len(body)}B" for svc, body in contents.items())
)
def test_env_names_are_two_distinct_keys():
"""Wymóg wprost: osobny klucz dla pary prezentacja-logika i logika-dane."""
assert ENV_PRESENTATION_LOGIC != ENV_LOGIC_DATA
# ------------------------------------------------- klient też musi być fail-closed
#
# To wyszło dopiero z podsłuchu prawdziwego gniazda, nie z testów: przy kliencie
# BEZ klucza serwer owszem odmawiał, ale pytanie leciało po drodze otwartym
# tekstem. Odpowiedź była chroniona — zapytanie już nie.
def test_client_without_key_sends_nothing_when_encryption_required(monkeypatch):
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
sent = []
class Tripwire:
def request(self, *args, **kwargs):
sent.append(args)
raise AssertionError("żądanie NIE powinno opuścić procesu")
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
link_crypto.call(Tripwire(), "POST", "http://logic/search",
payload={"value": "[Sat"}, link=None)
assert not sent, "treść zapytania wyszłaby jawnym tekstem"
def test_plaintext_still_allowed_in_dev(monkeypatch, link):
"""Bez tej flagi lokalne uruchomienie bez sekretów ma dalej działać."""
monkeypatch.delenv(link_crypto.ENV_REQUIRED, raising=False)
with TestClient(_app(None)) as client:
got = link_crypto.call_json(client, "POST", "http://testserver/search",
payload={"question": "Saturn"}, link=None)
assert got["interpretation"] == SECRET
def test_service_refuses_to_start_without_key_when_required(monkeypatch):
"""Pod w CrashLoop widać od razu; usługę, która wstała i nie szyfruje — nie."""
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
monkeypatch.delenv(ENV_LOGIC_DATA, raising=False)
with pytest.raises(LinkError, match=ENV_LOGIC_DATA):
link_crypto.install(FastAPI(), ENV_LOGIC_DATA, "danych")
+407
View File
@@ -0,0 +1,407 @@
"""Dostawcy LLM (LOG-31) — bez wołania jakiegokolwiek prawdziwego modelu.
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
deterministyczne i nic nie wychodzi na zewnątrz.
"""
import json
import httpx
import pytest
from app.llm import factory
from app.llm.base import Completion, LLMError
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
def _mock_client(handler):
"""Podmienia httpx.Client na wersję z transportem testowym."""
class _C(httpx.Client):
def __init__(self, *a, **kw):
kw["transport"] = httpx.MockTransport(handler)
super().__init__(*a, **kw)
return _C
@pytest.fixture
def chat_ok(monkeypatch):
def handler(request):
assert request.url.path.endswith("/chat/completions")
return httpx.Response(200, json={
"model": "test-model",
"choices": [{"message": {"content": "Horoskop testowy."}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 50},
})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
# ------------------------------------------------------- protokół chat/completions
def test_local_provider_generates(chat_ok):
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
out = p.generate("prompt", 500)
assert isinstance(out, Completion)
assert out.text == "Horoskop testowy."
assert out.usage["completion_tokens"] == 50
def test_local_provider_does_not_leave_lan(chat_ok):
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
assert p.generate("prompt", 100).leaves_lan is False
def test_cloud_provider_marks_leaving_lan(chat_ok):
p = ChatCompletionsProvider("openai", "https://api.openai.com/v1", "m", "klucz",
leaves_lan=True)
assert p.generate("prompt", 100).leaves_lan is True
def test_api_key_sent_only_when_set(monkeypatch):
seen = {}
def handler(request):
seen["auth"] = request.headers.get("authorization")
return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}]})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
assert seen["auth"] is None
ChatCompletionsProvider("openai", "http://x/v1", "m", "tajny").generate("p", 10)
assert seen["auth"] == "Bearer tajny"
def test_http_error_becomes_readable_message(monkeypatch):
monkeypatch.setattr(httpx, "Client",
_mock_client(lambda r: httpx.Response(400, text="zly model")))
with pytest.raises(LLMError, match="400"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
def test_malformed_response_reported(monkeypatch):
monkeypatch.setattr(httpx, "Client",
_mock_client(lambda r: httpx.Response(200, json={"nonsens": 1})))
with pytest.raises(LLMError, match="kształt"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
def test_retries_then_succeeds(monkeypatch):
calls = {"n": 0}
def handler(request):
calls["n"] += 1
if calls["n"] < 3:
return httpx.Response(429, text="za duzo")
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
monkeypatch.setattr("app.llm.providers.time.sleep", lambda s: None) # bez czekania
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10).text == "ok"
assert calls["n"] == 3
# ------------------------------------------------------------------- Anthropic
def test_anthropic_generates(monkeypatch):
def handler(request):
assert request.url.path.endswith("/v1/messages")
assert request.headers.get("x-api-key") == "klucz"
assert request.headers.get("anthropic-version")
return httpx.Response(200, json={
"model": "claude-x",
"content": [{"type": "text", "text": "Prognoza."}],
"usage": {"input_tokens": 10, "output_tokens": 5},
})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
assert out.text == "Prognoza." and out.leaves_lan is True
def test_anthropic_requires_key():
with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"):
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
# --------------------------------------------------------------------- fabryka
def test_default_provider_is_local(monkeypatch):
monkeypatch.delenv("LLM_PROVIDER", raising=False)
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("LLM_BASE_URL", raising=False)
p = factory.build_provider()
assert p.name == "local"
# domyślnie NIC nie opuszcza sieci — prompt niesie opisy z baz (LOG-32)
assert p.leaves_lan is False
def test_openai_requires_key(monkeypatch):
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
factory.build_provider("openai")
def test_unknown_provider_rejected():
with pytest.raises(LLMError, match="Nieznany dostawca"):
factory.build_provider("bzdura")
def test_env_overrides_model_and_url(monkeypatch):
monkeypatch.setenv("LLM_MODEL", "moj-model")
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
p = factory.build_provider("local")
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
# ---------------------------------------- konfiguracja per dostawca (regresja LOG-31)
# UI pozwala przelaczac dostawce przy kazdym zadaniu, wiec ustawienia JEDNEGO nie moga
# przeciekac na pozostalych. Wczesniej wspolne LLM_BASE_URL/LLM_MODEL kierowaly zadania
# do OpenAI na adres lokalnej Ollamy i prosily Anthropic o model llama.
def _clear(monkeypatch):
for v in ("LLM_PROVIDER", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_KEY",
"LOCAL_MODEL", "LOCAL_BASE_URL", "LOCAL_API_KEY",
"OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY",
"ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
monkeypatch.delenv(v, raising=False)
def test_local_config_does_not_leak_to_cloud(monkeypatch):
"""Sedno bledu: skonfigurowany model lokalny przejmowal zadania do chmury."""
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_BASE_URL", "http://ollama:11434/v1") # konfiguracja lokalnego
monkeypatch.setenv("LLM_MODEL", "llama3.1:8b")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
local = factory.build_provider("local")
assert local.base_url == "http://ollama:11434/v1" and local.model == "llama3.1:8b"
openai = factory.build_provider("openai")
assert openai.base_url == "https://api.openai.com/v1", "zadanie do OpenAI poszloby do Ollamy"
assert openai.model == "gpt-4o-mini", "OpenAI dostalby nazwe modelu llama"
def test_provider_specific_settings_win(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
p = factory.build_provider("anthropic")
assert p.model == "claude-opus-4-8" and p.api_key == "sk-ant"
def test_generic_vars_apply_only_to_default_provider(monkeypatch):
"""Zgodnosc wstecz: wspolne LLM_* konfiguruja dostawce domyslnego i tylko jego."""
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "openai")
monkeypatch.setenv("LLM_API_KEY", "sk-generic")
monkeypatch.setenv("LLM_MODEL", "gpt-4o")
assert factory.build_provider("openai").model == "gpt-4o"
assert factory.build_provider("local").model == "llama3.1:8b" # nie dziedziczy
def test_cloud_without_key_is_rejected_clearly(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
for name in ("openai", "anthropic"):
with pytest.raises(LLMError, match=f"{name.upper()}_API_KEY"):
factory.build_provider(name)
def test_local_needs_no_key(monkeypatch):
_clear(monkeypatch)
assert factory.build_provider("local").api_key == ""
# ------------------------------------------- pusta odpowiedz modelu (cicha awaria)
# Regresja: model potrafi oddac pusta tresc (prompt zjadl caly kontekst ->
# finish_reason=length, completion_tokens=0). Wczesniej generate() zwracalo pusty
# tekst BEZ bledu, widok nic nie renderowal i uzytkownik dostawal pusta strone
# bez zadnego wyjasnienia. Pusta odpowiedz MUSI byc bledem.
def test_empty_completion_raises_instead_of_silent_blank(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "llama3.1:8b",
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
def test_empty_completion_explains_context_window(monkeypatch):
"""Komunikat ma prowadzic do przyczyny, a nie tylko stwierdzac fakt."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": " "}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError) as ei:
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
msg = str(ei.value)
assert "kontekstu" in msg and "budżet" in msg
assert "powód zakończenia: length" in msg # diagnostyka w tresci bledu
def test_whitespace_only_is_treated_as_empty(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": "\n\n \t "}}],
})))
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100)
def test_anthropic_empty_completion_raises(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "claude-x", "content": [], "stop_reason": "max_tokens",
"usage": {"input_tokens": 9000, "output_tokens": 0},
})))
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
def test_normal_response_still_passes(monkeypatch):
"""Straznik nie moze psuc poprawnej odpowiedzi."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": "Horoskop."}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
})))
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100).text == "Horoskop."
# ------------------------------------- kontynuacja: horoskop MA powstac zawsze
# Sedno wymagania: niezaleznie od objetosci promptu i limitu wyjscia, pelna tresc
# ma wrocic do uzytkownika. Model urwany na max_tokens jest proszony o dokonczenie
# w ramach tej samej rozmowy, a kawalki sa sklejane.
def _scripted(responses):
"""Transport oddajacy kolejne odpowiedzi z listy (po jednej na ture)."""
seq = list(responses)
seen = []
def handler(request):
seen.append(request)
return httpx.Response(200, json=seq.pop(0) if seq else seq_last)
seq_last = responses[-1]
return handler, seen
def _chat(text, finish):
return {"choices": [{"message": {"content": text}, "finish_reason": finish}],
"usage": {"completion_tokens": 10}}
def test_truncated_answer_is_continued_and_joined(monkeypatch):
handler, seen = _scripted([
_chat("Czesc pierwsza.", "length"),
_chat("Czesc druga. KONIEC", "stop"),
])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
assert "Czesc pierwsza." in out.text and "Czesc druga." in out.text
assert "KONIEC" not in out.text # znacznik nie trafia do horoskopu
assert out.usage["turns"] == 2
def test_continuation_asks_in_same_conversation(monkeypatch):
"""Kontynuacja musi isc jako kolejna tura rozmowy, a ostatnia wiadomosc MUSI
byc od uzytkownika — Claude odrzuca prefill w turze asystenta (400)."""
handler, seen = _scripted([_chat("Poczatek", "length"), _chat("Reszta", "stop")])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
msgs = json.loads(seen[1].content)["messages"]
assert msgs[-1]["role"] == "user", "ostatnia wiadomosc nie moze byc prefillem asystenta"
assert msgs[1]["role"] == "assistant" and "Poczatek" in msgs[1]["content"]
def test_anthropic_thinking_only_turn_is_continued(monkeypatch):
"""DOKLADNIE zgloszony objaw: cala tura poszla na myslenie, tekst pusty.
Wczesniej konczylo sie to pusta strona; teraz pytamy o tresc dalej."""
seq = [
{"content": [{"type": "thinking", "thinking": ""}], "stop_reason": "max_tokens",
"usage": {"output_tokens": 2000}},
{"content": [{"type": "text", "text": "Horoskop urodzeniowy..."}],
"stop_reason": "end_turn", "usage": {"output_tokens": 500}},
]
def handler(request):
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 40000)
assert out.text == "Horoskop urodzeniowy..."
assert out.usage["turns"] == 2
def test_anthropic_sends_thinking_config(monkeypatch):
"""Bez jawnego `thinking` Sonnet 5 wlacza myslenie sam — konfigurujemy to wprost."""
seen = []
def handler(request):
seen.append(json.loads(request.content))
return httpx.Response(200, json={"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn"})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
monkeypatch.delenv("ANTHROPIC_THINKING", raising=False)
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
assert seen[0]["thinking"] == {"type": "adaptive"}
seen.clear()
monkeypatch.setenv("ANTHROPIC_THINKING", "off")
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
assert seen[0]["thinking"] == {"type": "disabled"}
def test_complete_answer_does_not_loop(monkeypatch):
"""Straznik nie moze mnozyc zapytan, gdy model skonczyl normalnie."""
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return httpx.Response(200, json=_chat("Gotowe.", "stop"))
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 40000)
assert out.text == "Gotowe." and calls["n"] == 1
# ------------------------------------------- wybor modelu przez uzytkownika (UI)
def test_model_from_request_wins_over_config(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
p = factory.build_provider("anthropic", "claude-fable-5")
assert p.model == "claude-fable-5", "wybor z UI musi wygrac nad konfiguracja"
def test_blank_model_falls_back_to_configured_default(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-sonnet-5")
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
assert factory.build_provider("anthropic", " ").model == "claude-sonnet-5"
def test_resolve_model_needs_no_api_key(monkeypatch):
"""Budzet promptu zalezy od okna kontekstu modelu — nie moze wymagac klucza.
Wczesniej liczenie budzetu szlo przez build_provider(), ktory bez klucza
rzuca bledem, wiec „maksymalny kontekst" cicho spadal do wartosci zapasowej.
"""
_clear(monkeypatch)
provider, model = factory.resolve_model("anthropic", "claude-haiku-4-5")
assert (provider, model) == ("anthropic", "claude-haiku-4-5")
with pytest.raises(LLMError): # samo zbudowanie nadal wymaga klucza
factory.build_provider("anthropic", "claude-haiku-4-5")
def test_max_budget_differs_between_models(monkeypatch):
"""Sedno funkcji: wieksze okno = wiekszy budzet promptu."""
from app.llm.limits import prompt_token_budget
_clear(monkeypatch)
opus = prompt_token_budget(*factory.resolve_model("anthropic", "claude-opus-4-8"))
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
assert opus > haiku > local > 0
+71
View File
@@ -0,0 +1,71 @@
"""Lots / punkty arabskie (LOG-08).
Wyrocznie z notes3 (astro-seek, horoskop referencyjny 30.04.1984, urodzenie DZIENNE):
- Fortuna wprost w tabeli obiektów: Cancer 12°35'24" = 102.5900°
- Ducha (Spirit) potwierdza jego antyscja: Taurus 28°14' -> Spirit = 180 58.2333
"""
import pytest
from app.engine.formats import sign_index
from app.engine.lots import LOT_DEFS, compute_lots
# natalne długości horoskopu referencyjnego
NATAL = {"Asc": 112.1786, "Sun": 40.1392, "Moon": 30.5514, "Mercury": 27.3839,
"Venus": 27.6769, "Mars": 234.5411, "Jupiter": 282.9617, "Saturn": 223.3108}
FORTUNE_REF = 102.5900 # Cancer 12°35'24"
SPIRIT_REF = 180.0 - 58.2333 # z antyscji Taurus 28°14'
def _by_name(lots):
return {lot["name"]: lot["longitude"] for lot in lots}
def _arcmin(a, b):
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
def test_fortune_matches_astroseek():
lots = _by_name(compute_lots(NATAL, is_day=True))
assert _arcmin(lots["Fortune"], FORTUNE_REF) < 2.0
assert sign_index(lots["Fortune"]) == 3 # Cancer
def test_spirit_matches_antiscia_reference():
lots = _by_name(compute_lots(NATAL, is_day=True))
assert _arcmin(lots["Spirit"], SPIRIT_REF) < 2.0
def test_fortune_and_spirit_reverse_at_night():
day = _by_name(compute_lots(NATAL, is_day=True))
night = _by_name(compute_lots(NATAL, is_day=False))
# nocą formuła się odwraca: Fortuna nocna = Duch dzienny (i odwrotnie)
assert _arcmin(night["Fortune"], day["Spirit"]) < 0.01
assert _arcmin(night["Spirit"], day["Fortune"]) < 0.01
def test_all_seven_hermetic_lots_present():
lots = compute_lots(NATAL, is_day=True)
assert [lot["name"] for lot in lots] == [d[0] for d in LOT_DEFS]
assert len(lots) == 7
def test_derived_lots_use_fortune_and_spirit():
lots = _by_name(compute_lots(NATAL, is_day=True))
# Necessity = Asc + Fortune Mercury
expected = (NATAL["Asc"] + lots["Fortune"] - NATAL["Mercury"]) % 360.0
assert _arcmin(lots["Necessity"], expected) < 0.01
# Eros = Asc + Venus Spirit
expected_eros = (NATAL["Asc"] + NATAL["Venus"] - lots["Spirit"]) % 360.0
assert _arcmin(lots["Eros"], expected_eros) < 0.01
def test_by_sign_method_lands_on_sign_start():
lots = compute_lots(NATAL, is_day=True, method="sign")
for lot in lots:
assert lot["longitude"] % 30.0 == 0.0 # 0° wyliczonego znaku
def test_unknown_method_rejected():
with pytest.raises(ValueError):
compute_lots(NATAL, is_day=True, method="bzdura")
+50
View File
@@ -0,0 +1,50 @@
"""Testy punktów wirtualnych (LOG-02): mean Node, mean Lilith.
Referencje dla 30.04.1984 07:35 UT:
- astro-seek (notes3): North Node (M) = Gem 8°09'24" = 68.1567°
- wyrocznia swisseph (MEAN_NODE / MEAN_APOG, tryb Moshiera):
NN = 68.1569°, Lilith = 345.6840°
"""
import pytest
from app.engine.formats import norm360
def _delta_arcmin(a: float, b: float) -> float:
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
@pytest.fixture(scope="module")
def by_name(own_engine, reference_moment):
return {p.name: p for p in own_engine.positions(reference_moment)}
def test_mean_node_matches_references(by_name):
nn = by_name["North Node"]
assert _delta_arcmin(nn.longitude, 68.1567) < 2.0 # astro-seek
assert _delta_arcmin(nn.longitude, 68.1569) < 2.0 # swisseph
assert nn.sign == "Gemini"
def test_nodes_always_retrograde_and_opposed(by_name):
nn, sn = by_name["North Node"], by_name["South Node"]
assert nn.retrograde and sn.retrograde # mean node zawsze Rx
assert _delta_arcmin(sn.longitude, norm360(nn.longitude + 180.0)) < 0.01
assert abs(nn.speed - sn.speed) < 1e-9 # ta sama prędkość
def test_mean_lilith_matches_swisseph(by_name):
li = by_name["Lilith"]
assert _delta_arcmin(li.longitude, 345.6840) < 3.0 # wyrocznia swisseph
assert li.sign == "Pisces"
assert li.speed > 0 and not li.retrograde # mean Lilith zawsze direct
def test_points_join_houses_and_chart(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
by = {p["name"]: p for p in chart["positions"]}
# NN w Gem -> 12. dom Whole Sign (Asc w Raku); zgodnie z tabelą astro-seek w notes3
assert by["North Node"]["house"] == 12
assert by["Lilith"]["house"] == 9 # Pis -> 9. dom
+46
View File
@@ -0,0 +1,46 @@
"""Profekcje (LOG-10) — walidacja względem tabeli astro-seek z notes3."""
import datetime as dt
from app.engine.profections import DOMICILE_RULERS, age_at, profected_sign, profection_rows
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
NATAL = {"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55}
# wiek -> (profektowany Asc, Władca Roku) — tabela referencyjna notes3
REFERENCE = {
0: ("Cancer", "Moon"), 1: ("Leo", "Sun"), 2: ("Virgo", "Mercury"),
3: ("Libra", "Venus"), 4: ("Scorpio", "Mars"), 5: ("Sagittarius", "Jupiter"),
6: ("Capricorn", "Saturn"), 7: ("Aquarius", "Saturn"), 8: ("Pisces", "Jupiter"),
9: ("Aries", "Mars"), 10: ("Taurus", "Venus"), 11: ("Gemini", "Mercury"),
12: ("Cancer", "Moon"), 40: ("Scorpio", "Mars"), 41: ("Sagittarius", "Jupiter"),
42: ("Capricorn", "Saturn"),
}
def test_profections_match_astroseek_table():
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 43)}
for age, (asc, lord) in REFERENCE.items():
assert rows[age]["profected_asc"] == asc, f"wiek {age}"
assert rows[age]["lord_of_year"] == lord, f"wiek {age}"
def test_profected_secondary_points_match_reference():
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 3)}
# notes3: wiek 0 -> MC Pis, Sun Tau, Moon Tau; wiek 1 -> MC Ari, Sun Gem
assert rows[0]["MC"] == "Pisces" and rows[0]["Sun"] == "Taurus"
assert rows[1]["MC"] == "Aries" and rows[1]["Sun"] == "Gemini"
def test_from_dates_are_birthdays():
rows = profection_rows(NATAL, BIRTH, 40, 3)
assert [r["from"] for r in rows] == ["2024-04-30", "2025-04-30", "2026-04-30"]
def test_age_at_boundaries():
assert age_at(BIRTH, dt.datetime(2026, 4, 29, tzinfo=dt.timezone.utc)) == 41
assert age_at(BIRTH, dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc)) == 42
def test_rulers_cover_all_signs():
assert len(DOMICILE_RULERS) == 12
assert profected_sign(112.18, 12) == "Cancer" # pełny cykl wraca
+218
View File
@@ -0,0 +1,218 @@
"""Generator promptów (LOG-29) i budżetowanie (LOG-30).
Testy nie wołają żadnego modelu — sprawdzają skład promptu i niezmienniki redukcji.
"""
import pytest
from app.prompt import (
BUDGETS,
build_natal_prompt,
build_period_prompt,
natal_indications,
period_indications,
reduce_indications,
)
CHART = {
"zodiac": "tropical",
"house_system": "whole_sign",
"sect": "day",
"positions": [
{"name": "Sun", "sign": "Taurus", "in_sign": "Tau 10°12'37\"", "house": 11, "direction": "D"},
{"name": "Moon", "sign": "Aries", "in_sign": "Ari 7°48'40\"", "house": 10, "direction": "D"},
],
"angles": {"Asc": {"name": "Asc", "in_sign": "Can 16°00'54\""},
"MC": {"name": "MC", "in_sign": "Pis 25°50'50\""}},
"lots": [{"name": "Fortune", "in_sign": "Can 7°15'14\"", "house": 1,
"formula": "Asc + Moon Sun"}],
"aspects": [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.34, "as": "A"}],
}
def _report(n_samples=3, score=2.0):
return {"objects": [{
"object": "Sun", "sign": "Taurus", "house": 11,
"facets": [{
"type": "sign", "label": "w znaku Taurus", "score": score,
"samples": [{"significator": f"[Su in [Tau {i}", "expanded": f"Sun in Taurus {i}",
"effect": f"efekt numer {i}"} for i in range(n_samples)],
}],
}]}
# ---------------------------------------------------------------- budżetowanie
def test_budget_limits_prompt_size():
# opisy MUSZĄ być różne — identyczne zlałoby grupowanie w jedną pozycję
big = {"objects": [{
"object": "Sun", "sign": "Taurus", "house": 11,
"facets": [{"type": "sign", "label": "w znaku Taurus", "score": 1.0,
"samples": [{"significator": f"[Su {i}", "expanded": f"Sun {i}",
"effect": f"opis {i} " + "x" * 200} for i in range(500)]}],
}]}
out = build_natal_prompt(CHART, big, budget="concise")
assert out["stats"]["chars"] <= BUDGETS["concise"] * 1.1 # z marginesem na sekcje stałe
assert out["stats"]["omitted"] > 0
def test_bigger_budget_includes_more():
big = _report(n_samples=300, score=1.0)
small = build_natal_prompt(CHART, big, budget="concise")["stats"]
large = build_natal_prompt(CHART, big, budget="extensive")["stats"]
assert large["included"] > small["included"]
assert large["omitted"] < small["omitted"]
def test_grouping_merges_identical_effects():
items = [
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
"significator": "Sun in Taurus", "effect": "ten sam opis"},
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
"significator": "Sun in Taurus (inny zapis)", "effect": "Ten Sam Opis "},
]
chosen, stats = reduce_indications(items, 10_000)
assert len(chosen) == 1 # zlane w jedno
assert chosen[0]["count"] == 2 # z licznikiem wystąpień
assert stats["after_grouping"] == 1
assert stats["deduplicated"] == 1
def test_sorted_by_score_desc():
items = [
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
"significator": f"s{i}", "effect": f"e{i}"} for i in range(5)
]
chosen, _ = reduce_indications(items, 10_000)
scores = [c["score"] for c in chosen]
assert scores == sorted(scores, reverse=True)
def test_weakest_are_dropped_first():
items = [
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
"significator": f"s{i}", "effect": "opis " * 20} for i in range(20)
]
chosen, stats = reduce_indications(items, 400)
assert stats["omitted"] > 0
# to, co weszło, ma wagę nie niższą niż to, co odpadło
assert stats["min_score_included"] >= stats["max_score_omitted"]
def test_long_effects_are_shortened():
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
"significator": "s", "effect": "x" * 1000}]
chosen, stats = reduce_indications(items, 10_000)
assert stats["shortened"] == 1
assert "skrócono" in chosen[0]["effect"]
def test_never_truncates_mid_indication():
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
"significator": "s", "effect": "opis " * 50} for _ in range(10)]
chosen, _ = reduce_indications(items, 200)
assert chosen # zawsze co najmniej jedno całe
for c in chosen:
assert c["effect"].endswith(("opis", "[…skrócono]")) # nie urwane w pół słowa
def test_unknown_budget_rejected():
with pytest.raises(ValueError):
build_natal_prompt(CHART, _report(), budget="gigantyczny")
# ----------------------------------------------------------------- skład promptu
def test_natal_prompt_contains_required_sections():
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
for section in ["# ZADANIE", "# DANE HOROSKOPU", "# WSKAZANIA Z BAZ",
"# JAK MA WYGLĄDAĆ ODPOWIEDŹ", "# ZASTRZEŻENIE"]:
assert section in p
assert "HOROSKOPU\nURODZENIOWEGO" in p or "URODZENIOWEGO" in p
def test_natal_prompt_carries_chart_data():
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
assert "Tau 10°12'37\"" in p and "dom 11" in p # pozycja + dom
assert "Can 16°00'54\"" in p # Asc
assert "Fortune" in p # Lots
assert "Sun conjunction Moon" in p or "conjunction" in p
assert "sekta: dzienna" in p
assert "1984-04-30 09:20 UTC" in p
def test_prompt_demands_citing_significators():
p = build_natal_prompt(CHART, _report())["prompt"]
assert "Teza bez wskazania jest niedopuszczalna" in p
assert "Nie zmyślaj" in p
def test_prompt_has_disclaimer():
p = build_natal_prompt(CHART, _report())["prompt"]
assert "nie stanowi porady" in p
def test_prompt_is_deterministic():
a = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
b = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
assert a == b
def test_omission_is_reported_in_prompt():
out = build_natal_prompt(CHART, _report(n_samples=400, score=1.0), budget="concise")
assert out["stats"]["omitted"] > 0
assert "pominięto" in out["prompt"] # użytkownik/model wie, że coś odpadło
def test_empty_report_still_builds_prompt():
out = build_natal_prompt(CHART, {"objects": []})
assert out["stats"]["included"] == 0
assert "brak trafień" in out["prompt"]
assert "# DANE HOROSKOPU" in out["prompt"]
# ------------------------------------------------------------------- okresowy
EVENTS = [
{"technique": "profection", "significator": "Lord of Year: Mars", "start": "2026-04-30",
"exact": "2026-04-30", "end": "2027-04-30", "interpretations_count": 2,
"interpretations": [{"significator": "[Ma", "expanded": "Mars", "effect": "opis marsowy"}]},
{"technique": "solar_arc", "significator": "Sun conj Saturn", "start": "2026-01-01",
"exact": "2026-06-15", "end": "2026-12-31", "interpretations_count": 1,
"interpretations": [{"significator": "[Su [conj [Sa", "expanded": "Sun conjunction Saturn",
"effect": "opis saturniczny"}]},
]
def test_period_prompt_has_dates_and_timeline():
out = build_period_prompt(CHART, EVENTS, "2026-01-01", "2027-01-01")
p = out["prompt"]
assert "2026-01-01 — 2027-01-01" in p
assert "# OŚ CZASU" in p
assert "profection" in p and "solar_arc" in p
assert "2026-06-15" in p # data dokładna zdarzenia
assert "CHRONOLOGICZNIE" in p
assert out["stats"]["events"] == 2
assert out["profile"] == "period"
def test_period_prompt_keeps_timeline_without_interpretations():
"""Padnięta warstwa danych zabiera wskazania, ale NIE oś czasu — ta jest czysto
obliczeniowa i bez niej prognoza okresowa jest bezużyteczna."""
bare = [{k: v for k, v in ev.items()
if k not in ("interpretations", "interpretations_count")} for ev in EVENTS]
out = build_period_prompt(CHART, bare, "2026-01-01", "2027-01-01")
assert out["stats"]["events"] == 2
assert "profection" in out["prompt"] and "2026-06-15" in out["prompt"]
assert out["stats"]["included"] == 0 # brak wskazań, ale oś czasu jest
def test_period_indications_weight_by_hit_count():
items = period_indications(EVENTS)
assert [i["score"] for i in items] == [2.0, 1.0]
def test_natal_indications_flatten_facets():
items = natal_indications(_report(n_samples=4))
assert len(items) == 4
assert all(i["context"].startswith("Sun —") for i in items)
assert all(i["score"] == 2.0 for i in items)
+40
View File
@@ -0,0 +1,40 @@
"""Solar / Lunar Return (LOG-12) — samospójność i sensowność dat."""
import datetime as dt
import pytest
from app.engine.models import ChartMoment
from app.engine.returns import find_return
def _lon(engine, body, when):
return engine.positions(ChartMoment(when_utc=when), [body])[0].longitude
def _delta_arcmin(a, b):
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
def test_solar_return_hits_natal_sun(own_engine, reference_moment):
natal_sun = _lon(own_engine, "Sun", reference_moment.when_utc)
# solariusz na 42. urodziny (2026)
hit = find_return(own_engine, "solar", reference_moment,
dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc))
assert hit is not None
assert _delta_arcmin(_lon(own_engine, "Sun", hit), natal_sun) < 0.5
assert hit.month == 4 and hit.year == 2026 # w okolicy urodzin
def test_lunar_return_hits_natal_moon(own_engine, reference_moment):
natal_moon = _lon(own_engine, "Moon", reference_moment.when_utc)
hit = find_return(own_engine, "lunar", reference_moment,
dt.datetime(1984, 5, 27, tzinfo=dt.timezone.utc))
assert hit is not None
assert _delta_arcmin(_lon(own_engine, "Moon", hit), natal_moon) < 2.0
def test_solar_return_near_birth_is_close_to_birth(own_engine, reference_moment):
# powrót szukany wokół samych urodzin = ~moment urodzenia
hit = find_return(own_engine, "solar", reference_moment, reference_moment.when_utc)
assert hit is not None
assert abs(hit - reference_moment.when_utc) < dt.timedelta(days=1)
+65
View File
@@ -0,0 +1,65 @@
"""Token międzywarstwowy (LOG-32).
Warstwa logiczna oddaje treść baz, więc musi odrzucać żądania z pominięciem UI.
"""
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from app import security
TOKEN = "tajny-token-testowy"
def _app() -> FastAPI:
app = FastAPI()
security.install(app, "testowa")
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/secret")
def secret():
return {"rows": ["treść z bazy"]}
return app
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setenv("INTERNAL_TOKEN", TOKEN)
return TestClient(_app())
@pytest.fixture
def open_app(monkeypatch):
monkeypatch.delenv("INTERNAL_TOKEN", raising=False)
return TestClient(_app())
def test_rejects_request_without_token(guarded):
assert guarded.get("/secret").status_code == 401
def test_rejects_wrong_token(guarded):
r = guarded.get("/secret", headers={security.HEADER: "zly"})
assert r.status_code == 401
assert "treść z bazy" not in r.text
def test_accepts_correct_token(guarded):
r = guarded.get("/secret", headers={security.HEADER: TOKEN})
assert r.status_code == 200
assert r.json()["rows"] == ["treść z bazy"]
def test_health_stays_public(guarded):
"""Sonda k8s nie zna tokenu — /health musi działać bez niego."""
assert guarded.get("/health").status_code == 200
def test_disabled_when_token_unset(open_app):
"""Brak konfiguracji = zgodność wstecz (dev), nie blokada."""
assert not security.enabled()
assert open_app.get("/secret").status_code == 200
+55
View File
@@ -0,0 +1,55 @@
"""Testy wykrywania stacji (LOG-03).
Fakt historyczny: Mars w horoskopie referencyjnym (30.04.1984) jest w środku
retrogradacji — stacja SR ~5.04.1984 (ok. 25 dni wstecz), stacja SD ~19.06.1984
(ok. 50 dni w przód). Testy sprawdzają strukturę, klasyfikację SD/SR, przedziały
dat i samospójność (prędkość w znalezionym momencie ~0).
"""
import datetime as dt
import pytest
from app.engine.models import ChartMoment
from app.engine.stations import STATION_SOON_DAYS, find_stations
@pytest.fixture(scope="module")
def mars_stations(own_engine, reference_moment):
return find_stations(own_engine, reference_moment, "Mars")
def test_no_stations_for_sun_moon_and_points(own_engine, reference_moment):
for name in ("Sun", "Moon", "North Node", "South Node", "Lilith"):
assert find_stations(own_engine, reference_moment, name) is None
def test_mars_prev_is_sr_next_is_sd(mars_stations):
assert mars_stations["prev"]["type"] == "SR" # wszedł w retrogradację
assert mars_stations["next"]["type"] == "SD" # wróci do ruchu prostego
def test_mars_station_windows_match_history(mars_stations):
# SR ~5.04.1984 -> ok. -25 dni; SD ~19/20.06.1984 -> ok. +50 dni
assert -35 < mars_stations["prev"]["days"] < -15
assert 40 < mars_stations["next"]["days"] < 60
assert mars_stations["prev"]["date"].startswith("1984-04")
assert mars_stations["next"]["date"].startswith("1984-06")
def test_station_speed_is_near_zero(own_engine, reference_moment, mars_stations):
"""Samospójność: w znalezionym momencie stacji prędkość Marsa ~0."""
for key in ("prev", "next"):
when = dt.datetime.strptime(mars_stations[key]["date"], "%Y-%m-%d %H:%M").replace(
tzinfo=dt.timezone.utc
)
m = ChartMoment(when_utc=when, lat=reference_moment.lat, lon=reference_moment.lon)
speed = own_engine.positions(m, ["Mars"])[0].speed
assert abs(speed) < 0.01, f"{key}: speed={speed}"
def test_station_soon_flag_consistent(mars_stations):
expected = any(
abs(mars_stations[k]["days"]) < STATION_SOON_DAYS
for k in ("prev", "next") if k in mars_stations
)
assert mars_stations["station_soon"] == expected
+71
View File
@@ -0,0 +1,71 @@
"""Zbiorcza oś czasu z technik (LOG-14)."""
import datetime as dt
from app.engine.timeline import (
NAIBOD_KEY,
build_timeline,
profection_events,
solar_arc_directions,
)
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
# natalne długości (z horoskopu referencyjnego)
NATAL = {
"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55, "Mercury": 27.38,
"Venus": 27.68, "Mars": 234.54, "Jupiter": 282.96, "Saturn": 223.31,
"Uranus": 252.81, "Neptune": 271.22, "Pluto": 210.48,
}
def _win(y0, y1):
return (dt.datetime(y0, 1, 1, tzinfo=dt.timezone.utc),
dt.datetime(y1, 12, 31, tzinfo=dt.timezone.utc))
def test_profection_events_in_window():
lo, hi = _win(2024, 2026)
rows = profection_events(NATAL["Asc"], BIRTH, lo, hi)
# rok profekcyjny wiek 42 zaczyna się 30.04.2026 -> Asc Capricorn
ages = [r["significator"] for r in rows]
assert any("Asc Capricorn" in a and "wiek 42" in a for a in ages)
def test_solar_arc_exact_matches_arc_over_key():
lo, hi = _win(2020, 2030)
rows = solar_arc_directions(NATAL, BIRTH, lo, hi, key=NAIBOD_KEY)
assert rows, "brak dyrekcji w oknie"
# dla każdej dyrekcji: łuk = (wiek * klucz), a data = urodziny + wiek -> spójne
for r in rows[:5]:
exact = dt.date.fromisoformat(r["exact"])
age_years = (dt.datetime(exact.year, exact.month, exact.day, tzinfo=dt.timezone.utc)
- BIRTH).days / 365.2422
assert 36 <= age_years <= 47 # okno 2020-2030 = wiek ~36-46
assert r["technique"] == "solar_arc" and "dyr." in r["significator"]
def test_directed_sun_conjunct_natal_mc_date():
# Sun natal 40.14 -> MC natal 352.59: łuk koniunkcji = (352.59-40.14)%360 = 312.45
# to > lifespan przy Naibod (~317 lat) -> NIE powinno być w oknie życia
lo, hi = _win(1984, 2084)
rows = solar_arc_directions(NATAL, BIRTH, lo, hi)
sun_mc = [r for r in rows if r["significator"] == "dyr. Sun koniunkcja MC"]
assert not sun_mc # łuk 312° = poza życiem
def test_build_timeline_sorted_and_merged():
events = build_timeline(_FakeEngine(), _FakeNatal(), NATAL,
"2025-01-01", "2027-01-01",
techniques=["profection", "solar_arc"])
assert events
dates = [e["exact"] for e in events]
assert dates == sorted(dates) # posortowane po dacie dokładnej
techs = {e["technique"] for e in events}
assert "profection" in techs and "solar_arc" in techs
class _FakeNatal:
when_utc = BIRTH
class _FakeEngine:
"""Silnik-atrapa — build_timeline z solar_return by go użył, tu go pomijamy."""
@@ -0,0 +1,46 @@
"""Spięcie osi czasu z bazą interpretacji (LOG-14 → 1B→2B)."""
from app.significators import _event_tokens, interpret_events
class FakeData:
def __init__(self, rows_by_value):
self.rows_by_value = rows_by_value
def search(self, key, value, exact, limit, fields=None):
rows = self.rows_by_value.get(value, [])
return {"provider": "fake", "total": len(rows), "rows": rows}
def test_event_tokens_solar_arc():
ev = {"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"}
assert _event_tokens(ev) == ["[Ve", "[conj", "[NN"]
def test_event_tokens_solar_arc_to_angle_has_no_target_token():
ev = {"technique": "solar_arc", "directed": "Sun", "aspect": "square", "target": "MC"}
assert _event_tokens(ev) == ["[Su", "[sq"] # MC nie ma tokenu planety
def test_event_tokens_profection():
ev = {"technique": "profection", "lord": "Saturn", "sign": "Capricorn"}
assert _event_tokens(ev) == ["[Sa", "[Cap"]
def test_solar_return_has_no_tokens():
assert _event_tokens({"technique": "solar_return"}) == []
def test_interpret_attaches_matches_with_all_tokens():
events = [
{"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"},
{"technique": "solar_return"},
]
data = FakeData({"[Ve": [
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # wszystkie tokeny
{"significator": "[Ve [conj [Mo", "actioneffect": "inny"}, # brak [NN
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # duplikat
]})
interpret_events(events, data)
assert events[0]["interpretations_count"] == 1 # duplikat odsiany, tylko z [NN
assert events[0]["interpretations"][0]["expanded"] == "Venus conjunction North Node"
assert "interpretations" not in events[1] # solar_return pominięty
+127
View File
@@ -0,0 +1,127 @@
"""Systemy zodiaku (LOG-04): ayanamsy, syderyczny, draconic, RA.
Wartości referencyjne ayanams pochodzą ze **Swiss Ephemeris** (get_ayanamsa_ut)
użytego jako wyrocznia — silnika B nie importujemy tu (izolacja AGPL), więc stałe
są wpięte tak jak referencje astro-seek w innych testach. Nasz model odtwarza je
z dokładnością do ~0,02" w latach 19002100.
"""
import math
import pytest
from app.engine import zodiac as Z
from app.engine.chart import build_chart
from app.engine.formats import sign_index
# wyrocznia: swisseph get_ayanamsa_ut (JD UT -> ayanamsa °)
ORACLE = {
"sidereal_lahiri": {
2451545.0: 23.857092, 2445820.88889: 23.638183,
2415021.0: 22.460550, 2433283.0: 23.158744,
2469808.0: 24.555632, 2488070.0: 25.254287,
},
"sidereal_fagan_bradley": {
2451545.0: 24.740300, 2445820.88889: 24.521391,
2415021.0: 23.343757, 2433283.0: 24.041952,
2469808.0: 25.438840, 2488070.0: 26.137495,
},
"sidereal_krishnamurti": {
2451545.0: 23.760240, 2415021.0: 22.363697,
2469808.0: 24.458780, 2488070.0: 25.157435,
},
}
def _arcsec(a, b):
return abs(a - b) * 3600.0
@pytest.mark.parametrize("name", list(ORACLE))
def test_ayanamsha_matches_swisseph_oracle(name):
for jd, ref in ORACLE[name].items():
got = Z.ayanamsha(name, jd)
assert _arcsec(got, ref) < 0.1, f"{name} @ JD{jd}: {got} vs {ref} ({_arcsec(got, ref):.3f}\")"
def test_julian_day_j2000():
from datetime import datetime, timezone
jd = Z.julian_day(datetime(2000, 1, 1, 12, 0, tzinfo=timezone.utc))
assert abs(jd - 2451545.0) < 1e-6
def test_offset_tropical_is_zero():
assert Z.offset("tropical", 2451545.0) == 0.0
def test_offset_draconic_is_node_longitude():
assert Z.offset("draconic", 2451545.0, node_lon=68.0) == 68.0
# normalizacja
assert Z.offset("draconic", 2451545.0, node_lon=428.0) == pytest.approx(68.0)
def test_offset_draconic_requires_node():
with pytest.raises(ValueError):
Z.offset("draconic", 2451545.0)
def test_unknown_zodiac_rejected():
with pytest.raises(ValueError):
Z.offset("bzdura", 2451545.0)
with pytest.raises(ValueError):
Z.ayanamsha("sidereal_nieistnieje", 2451545.0)
def test_apply_subtracts_and_normalizes():
assert Z.apply(10.0, 20.0) == pytest.approx(350.0)
assert Z.apply(40.0, 24.74) == pytest.approx(15.26)
def test_to_equatorial_reference_points():
# punkt równonocy: λ=0,β=0 -> RA=0, dec=0
ra, dec = Z.to_equatorial(0.0, 0.0, 23.4392911)
assert ra == pytest.approx(0.0, abs=1e-9) and dec == pytest.approx(0.0, abs=1e-9)
# przesilenie letnie: λ=90,β=0 -> RA=90, dec=+ε
ra, dec = Z.to_equatorial(90.0, 0.0, 23.4392911)
assert ra == pytest.approx(90.0, abs=1e-6)
assert dec == pytest.approx(23.4392911, abs=1e-6)
# ---- integracja z pełnym horoskopem ----
def test_sidereal_chart_shifts_signs_back(own_engine, reference_moment):
trop = build_chart(own_engine, reference_moment, "whole_sign")
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
assert sid["zodiac"] == "sidereal_lahiri"
assert "ayanamsha" in sid and 23.0 < sid["ayanamsha"] < 24.5
tp = {p["name"]: p for p in trop["positions"]}
sp = {p["name"]: p for p in sid["positions"]}
# syderyczna długość = tropikalna ayanamsa (mod 360) dla każdego obiektu
ay = sid["ayanamsha"]
for name in tp:
expect = (tp[name]["decimal"] - ay) % 360.0
assert abs(((sp[name]["decimal"] - expect + 180) % 360) - 180) < 1e-4
def test_sidereal_preserves_house_numbers(own_engine, reference_moment):
# obrót zodiaku nie zmienia numerów domów (geometria niezmiennicza)
trop = build_chart(own_engine, reference_moment, "whole_sign")
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
tp = {p["name"]: p["house"] for p in trop["positions"]}
sp = {p["name"]: p["house"] for p in sid["positions"]}
assert tp == sp
def test_draconic_puts_node_at_zero_aries(own_engine, reference_moment):
drac = build_chart(own_engine, reference_moment, "whole_sign", zodiac="draconic")
assert drac["zodiac"] == "draconic"
nn = next(p for p in drac["positions"] if p["name"] == "North Node")
# węzeł wznoszący = 0° Barana w draconic
assert sign_index(nn["decimal"]) == 0
assert nn["decimal"] < 0.001 or nn["decimal"] > 359.999
def test_tropical_default_unchanged(own_engine, reference_moment):
# domyślnie tropikalny: brak ayanamshy, zodiac=tropical
chart = build_chart(own_engine, reference_moment, "whole_sign")
assert chart["zodiac"] == "tropical"
assert "ayanamsha" not in chart
@@ -5,23 +5,44 @@ opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
return link_crypto.Link(key) if key else None
class LogicClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
with httpx.Client(timeout=timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(),
link=_link())
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/api/query", json=payload)
r.raise_for_status()
return r.json()
return self._post("/api/query", payload, settings.http_timeout)
def positions(
self,
@@ -30,6 +51,8 @@ class LogicClient:
lon: float,
objects: list[str] | None = None,
house_system: str = "whole_sign",
stations: bool = False,
zodiac: str = "tropical",
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
@@ -38,18 +61,72 @@ class LogicClient:
"lon": lon,
"objects": objects,
"house_system": house_system,
"stations": stations,
"zodiac": zodiac,
}
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/chart/positions", json=payload)
r.raise_for_status()
return r.json()
# stacje wymagają root-findów — dłuższy timeout
timeout = max(settings.http_timeout, 60.0) if stations else settings.http_timeout
return self._post("/chart/positions", payload, timeout)
def report(
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
) -> dict[str, Any]:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/chart/report", json=payload)
r.raise_for_status()
return r.json()
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
def prompt(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
provider: str | None = None, model: str | None = None,
) -> dict[str, Any]:
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
"model": model,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
def horoscope(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", provider: str | None = None, model: str | None = None,
from_date: str | None = None, to_date: str | None = None,
) -> dict[str, Any]:
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
"""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget,
}
if provider:
payload["provider"] = provider
if model:
payload["model"] = model
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
def llm_models(self) -> dict[str, Any]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
headers=_auth_headers(), link=_link())
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))
+14
View File
@@ -15,3 +15,17 @@ class Settings:
settings = Settings()
# Domyślna (wklepana) lokalizacja formularzy — miejsce urodzenia właściciela:
# Szpital Barlickiego w Łodzi, 51°46'26.1"N 19°28'58.6"E (potwierdzone reverse-
# geokodowaniem OSM: Kopcińskiego 22/28). QoL — nie trzeba wpisywać jej za każdym
# razem. Przycisk „Tu i teraz" nadal ją nadpisuje geolokalizacją przeglądarki.
# Można nadpisać zmiennymi środowiskowymi (np. inny współpracownik).
DEFAULT_LAT: float = float(os.getenv("DEFAULT_LAT", "51.7739"))
DEFAULT_LON: float = float(os.getenv("DEFAULT_LON", "19.4829"))
DEFAULT_LOCATION_LABEL: str = os.getenv("DEFAULT_LOCATION_LABEL", "Szpital Barlickiego, Łódź")
def default_form() -> dict:
"""Wstępnie wypełnione pola lokalizacji na czystym formularzu (GET)."""
return {"lat": DEFAULT_LAT, "lon": DEFAULT_LON}
+95
View File
@@ -0,0 +1,95 @@
"""Proxy geokodera (OpenStreetMap / Nominatim) dla wyszukiwarki lokalizacji w UI.
QoL: pozwala wpisać nazwę / adres / POI i dostać współrzędne (oraz odwrotnie —
z punktu na mapie nazwę miejsca). Wołamy Nominatim **po stronie serwera**, nie
z przeglądarki, bo:
- polityka Nominatim wymaga sensownego User-Agent i max ~1 zapytanie/s — łatwiej
to wyegzekwować i cache'ować centralnie niż z wielu klientów,
- działa niezależnie od „secure context" przeglądarki (na http://<ip> też).
Atrybucja „© OpenStreetMap contributors" jest pokazywana w UI (mapa + wyniki).
Zależność sieciowa jest OPCJONALNA: brak internetu = wyszukiwarka zwraca błąd,
ale ręczne wpisanie lat/lon oraz mapa działają dalej.
"""
from __future__ import annotations
import threading
import time
from typing import Any
import httpx
NOMINATIM = "https://nominatim.openstreetmap.org"
# Nominatim wymaga identyfikującego User-Agent (inaczej blokuje). Kontakt = właściciel.
USER_AGENT = "astrololo/1.0 (self-hosted astrology tool; +mtuszowski@gmail.com)"
_MIN_INTERVAL = 1.1 # s — polityka Nominatim: max ~1 zapytanie/s
_CACHE_TTL = 3600.0 # s — powtórki tego samego zapytania z pamięci przez godzinę
_TIMEOUT = 6.0
_lock = threading.Lock() # serializuje wołania (throttling globalny w procesie)
_last_call = 0.0
_cache: dict[str, tuple[float, Any]] = {}
def _throttle() -> None:
global _last_call
wait = _MIN_INTERVAL - (time.monotonic() - _last_call)
if wait > 0:
time.sleep(wait)
_last_call = time.monotonic()
def _cached(key: str) -> Any | None:
hit = _cache.get(key)
if hit and (time.monotonic() - hit[0]) < _CACHE_TTL:
return hit[1]
return None
def _get(path: str, params: dict) -> Any:
"""Wołanie Nominatim z throttlingiem; zakłada trzymany `_lock`."""
_throttle()
r = httpx.get(
f"{NOMINATIM}{path}",
params=params,
headers={"User-Agent": USER_AGENT, "Accept-Language": "pl"},
timeout=_TIMEOUT,
)
r.raise_for_status()
return r.json()
def search(query: str, limit: int = 5) -> list[dict]:
"""Nazwa/adres/POI → lista kandydatów [{name, lat, lon}] (najlepsze pierwsze)."""
query = (query or "").strip()
if not query:
return []
key = f"s:{limit}:{query.lower()}"
with _lock:
cached = _cached(key)
if cached is not None:
return cached
data = _get("/search", {"q": query, "format": "jsonv2",
"limit": limit, "addressdetails": 0})
out = [
{"name": item.get("display_name", ""),
"lat": round(float(item["lat"]), 6),
"lon": round(float(item["lon"]), 6)}
for item in data if "lat" in item and "lon" in item
]
_cache[key] = (time.monotonic(), out)
return out
def reverse(lat: float, lon: float) -> dict:
"""Punkt (lat, lon) → nazwa miejsca {name, lat, lon}."""
key = f"r:{round(lat, 5)}:{round(lon, 5)}"
with _lock:
cached = _cached(key)
if cached is not None:
return cached
data = _get("/reverse", {"lat": lat, "lon": lon, "format": "jsonv2"})
out = {"name": data.get("display_name", ""),
"lat": round(lat, 6), "lon": round(lon, 6)}
_cache[key] = (time.monotonic(), out)
return out
+466
View File
@@ -0,0 +1,466 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
try:
return self._by_direction[direction].decrypt(
nonce, sealed, self._aad(direction, path, stamp, seq))
except InvalidTag as exc:
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
# ------------------------------------------------------------ strumień ramek
def seal_stream(self, direction: bytes, path: str, stamp: str,
chunks: Iterable[bytes]) -> Iterator[bytes]:
for seq, chunk in enumerate(chunks):
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
def open_stream(self, direction: bytes, path: str, stamp: str,
raw: bytes) -> Iterator[bytes]:
for seq, frame in enumerate(frames_in(raw)):
yield self.open(direction, path, stamp, seq, frame)
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
return b"".join(self.open_stream(direction, path, stamp, raw))
# ---------------------------------------------------------------- ramkowanie
def frame_out(payload: bytes) -> bytes:
return struct.pack(">I", len(payload)) + payload
def frames_in(raw: bytes) -> Iterator[bytes]:
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
offset = 0
while offset < len(raw):
if offset + LENGTH_PREFIX > len(raw):
raise LinkError("urwana ramka (brak nagłówka długości)")
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
offset += LENGTH_PREFIX
if offset + size > len(raw):
raise LinkError("urwana ramka (za mało danych)")
yield raw[offset:offset + size]
offset += size
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
rozjechać się między dwa odczyty.
"""
while True:
if len(buffer) < LENGTH_PREFIX:
return
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
if len(buffer) < LENGTH_PREFIX + size:
return
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
del buffer[:LENGTH_PREFIX + size]
yield frame
# ------------------------------------------------------------- świeżość ruchu
def stamp_now() -> str:
return f"{time.time():.3f}"
def check_stamp(stamp: str) -> None:
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
odtworzyć w dowolnym momencie w przyszłości."""
try:
sent = float(stamp)
except (TypeError, ValueError) as exc:
raise LinkError("brak albo błędny znacznik czasu") from exc
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
# =========================================================== strona serwerowa
class LinkCryptoMiddleware:
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
"""
def __init__(self, app, link: Link | None, layer: str) -> None:
self.app = app
self.link = link
self.layer = layer
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
return await self.app(scope, receive, send)
path = scope["path"]
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
if headers.get(HEADER_ENC.lower()) != VERSION:
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
# chcemy po cichu wrócić do jawnego ruchu.
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
self.layer, path)
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
stamp = headers.get(HEADER_TS.lower(), "")
try:
check_stamp(stamp)
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
except LinkError as exc:
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
scope = dict(scope)
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
def _sealing_send(self, send, path: str):
state: dict = {"stamp": "", "seq": 0}
async def sealing(message):
if message["type"] == "http.response.start":
state["stamp"] = stamp_now()
keep = [(k, v) for k, v in message.get("headers", [])
if k.lower() not in (b"content-length", b"content-type")]
message = dict(message)
message["headers"] = keep + [
(b"content-type", CONTENT_TYPE.encode()),
(HEADER_ENC.lower().encode(), VERSION.encode()),
(HEADER_TS.lower().encode(), state["stamp"].encode()),
]
return await send(message)
if message["type"] == "http.response.body":
chunk = message.get("body", b"")
sealed = b""
if chunk:
sealed = frame_out(self.link.seal(
RESPONSE, path, state["stamp"], state["seq"], chunk))
state["seq"] += 1
return await send({"type": "http.response.body", "body": sealed,
"more_body": message.get("more_body", False)})
return await send(message)
return sealing
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
kept.append((b"content-length", str(length).encode()))
if length:
kept.append((b"content-type", b"application/json"))
return kept
async def _read_body(receive) -> bytes:
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
raise LinkError("rozłączenie w trakcie odbioru żądania")
body += message.get("body", b"")
if not message.get("more_body", False):
return bytes(body)
def _replay(body: bytes, original):
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
"""
delivered = False
async def receive():
nonlocal delivered
if delivered:
return await original()
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return receive
async def _refuse(send, detail: str) -> None:
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
await send({"type": "http.response.start", "status": 400, "headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
]})
await send({"type": "http.response.body", "body": payload})
def install(app, env_name: str, layer: str):
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
link_key = key_from_env(env_name)
if link_key is None and encryption_required():
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
f"warstwa {layer} nie wystartuje bez klucza łącza"
)
if link_key is None:
log.warning(
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
"ruch wewnątrz sieci.", env_name, layer,
)
return None
link = Link(link_key)
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
return link
# ============================================================ strona kliencka
def call(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
się niejasnym błędem odszyfrowania.
"""
import json as _json
import httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
# sygnifikatory, o które pytamy bazę.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
)
response = client.request(method, url, json=payload, headers=request_headers)
response.raise_for_status()
return response.content
path = httpx.URL(url).path
stamp = stamp_now()
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
"Content-Type": CONTENT_TYPE})
response = client.request(method, url, content=body, headers=request_headers)
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
log.error("łącze %s odmówiło: %s", path, response.text[:200])
response.raise_for_status()
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
reply_stamp = response.headers.get(HEADER_TS, "")
check_stamp(reply_stamp)
return link.open_all(RESPONSE, path, reply_stamp, response.content)
def call_json(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None):
import json as _json
return _json.loads(call(client, method, url, payload=payload,
headers=headers, link=link))
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
w buforze zamiast zakładać, że każdy kawałek to komplet.
"""
if link is None:
yield from response.iter_bytes()
return
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
stamp = response.headers.get(HEADER_TS, "")
check_stamp(stamp)
path = response.request.url.path
buffer = bytearray()
seq = 0
for chunk in response.iter_bytes():
buffer += chunk
for frame in unframe_incremental(buffer):
yield link.open(RESPONSE, path, stamp, seq, frame)
seq += 1
if buffer:
raise LinkError("strumień urwał się w połowie ramki")
+125 -8
View File
@@ -12,17 +12,20 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import FastAPI, Form, Request
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app import geocode, security
from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form
app = FastAPI(title="astrololo · warstwa prezentacji")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
@@ -36,6 +39,15 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
return utc.isoformat(), label
def _llm_catalog() -> dict:
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
try:
return logic.llm_models()
except httpx.HTTPError:
return {"providers": {}, "defaults": {}}
def _logic_error(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
return (
@@ -48,7 +60,11 @@ def _logic_error(e: Exception) -> str:
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def chart_form(request: Request):
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
)
@app.post("/", response_class=HTMLResponse)
@@ -60,15 +76,19 @@ def chart_compute(
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
stations: bool = Form(False),
zodiac: str = Form("tropical"),
):
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system}
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
"zodiac": zodiac}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon, house_system=house_system
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, stations=stations, zodiac=zodiac,
)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
@@ -103,7 +123,11 @@ def significators_search(
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
@app.get("/interpret", response_class=HTMLResponse)
def interpret_form(request: Request):
return templates.TemplateResponse(request, "interpret.html", {"result": None, "form": {}})
return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
)
@app.post("/interpret", response_class=HTMLResponse)
@@ -115,14 +139,31 @@ def interpret_run(
lat: float = Form(0.0),
lon: float = Form(0.0),
group: bool = Form(False),
action: str = Form("report"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
"llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
if action == "prompt":
ctx["prompt_result"] = logic.prompt(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
provider=llm_provider, model=llm_model,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, provider=llm_provider, model=llm_model,
)
else:
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
@@ -130,6 +171,82 @@ def interpret_run(
return templates.TemplateResponse(request, "interpret.html", ctx)
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
@app.get("/timeline", response_class=HTMLResponse)
def timeline_form(request: Request):
return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
)
@app.post("/timeline", response_class=HTMLResponse)
def timeline_run(
request: Request,
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
from_date: str = Form(...),
to_date: str = Form(...),
action: str = Form("timeline"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
"llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
if action == "prompt":
ctx["prompt_result"] = logic.prompt(
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, from_date=from_date, to_date=to_date,
provider=llm_provider, model=llm_model,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, provider=llm_provider, model=llm_model,
from_date=from_date, to_date=to_date,
)
else:
ctx["result"] = logic.timeline(
when_utc_iso=iso_utc, lat=lat, lon=lon,
from_date=from_date, to_date=to_date, interpret=True,
)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "timeline.html", ctx)
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
@app.get("/geocode")
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
try:
return {"results": geocode.search(q)}
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/reverse")
def geocode_reverse(lat: float, lon: float):
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
try:
return geocode.reverse(lat, lon)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/health")
def health() -> dict:
return {"status": "ok", "layer": "presentation"}
+151
View File
@@ -0,0 +1,151 @@
"""Ochrona dostępu do aplikacji (LOG-32).
Rdzeniem produktu są oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
uwierzytelnienia oznacza, że każdy w sieci może je wypompować — bez udziału
jakiegokolwiek modelu językowego. Ten moduł zamyka tę drogę.
Dwa mechanizmy:
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
* **limit żądań** — hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań);
rozliczany per adres klienta, a za odwrotnym proxy — po TRUST_PROXY=true —
per adres z nagłówka, nie per adres proxy (patrz `client_ip`).
Świadomie NIE logujemy treści żądań ani promptów — logi to kolejny nośnik wycieku.
UWAGA: bez APP_PASSWORD ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev).
Wtedy przy starcie leci głośne ostrzeżenie — żeby nikt nie wdrożył tego w
przekonaniu, że jest chroniony.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import secrets
import time
from collections import deque
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse
log = logging.getLogger("astrololo.security")
MAX_TRACKED_CLIENTS_DEFAULT = 4096
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
# konfiguracja czytana leniwie — testy i restart mogą ją zmienić bez importu modułu
def app_user() -> str:
return os.getenv("APP_USER", "astrololo")
def app_password() -> str:
return os.getenv("APP_PASSWORD", "")
def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
def trust_proxy() -> bool:
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
PUBLIC_PATHS = frozenset({"/health"})
PUBLIC_PREFIXES = ("/static/",)
_hits: dict[str, deque[float]] = {}
def auth_enabled() -> bool:
return bool(app_password())
def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
def _authorized(header: str | None) -> bool:
if not header or not header.lower().startswith("basic "):
return False
try:
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError):
return False
# porównanie odporne na atak czasowy; oba pola muszą się zgadzać
ok_user = secrets.compare_digest(user, app_user())
ok_pass = secrets.compare_digest(password, app_password())
return ok_user and ok_pass
def client_ip(request: Request) -> str:
"""Adres, po którym rozliczamy limit żądań.
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
wyklika limit, odcięłaby pozostałe.
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
klient, więc nie znaczą nic.
"""
peer = request.client.host if request.client else "?"
if not trust_proxy():
return peer
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
last = forwarded.rsplit(",", 1)[-1].strip()
if last:
return last
return request.headers.get("x-real-ip", "").strip() or peer
def _rate_limited(client: str) -> bool:
cap = rate_limit_per_min()
if cap <= 0:
return False
now = time.monotonic()
window = _hits.get(client)
if window is None:
if len(_hits) >= MAX_TRACKED_CLIENTS:
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
window = _hits[client] = deque()
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= cap:
return True
window.append(now)
return False
def install(app) -> None:
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
if not auth_enabled():
log.warning(
"UWAGA: APP_PASSWORD nie ustawione — aplikacja jest OTWARTA dla każdego, "
"kto ma do niej dostęp sieciowy, wraz z treścią baz interpretacyjnych."
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if _is_public(request.url.path):
return await call_next(request)
if _rate_limited(client_ip(request)):
return JSONResponse(
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
status_code=429, headers={"Retry-After": "60"},
)
if auth_enabled() and not _authorized(request.headers.get("authorization")):
return HTMLResponse(
"<h1>401 — wymagane logowanie</h1>", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
)
return await call_next(request)
+31
View File
@@ -0,0 +1,31 @@
// Kopiowanie do schowka dla przycisków [data-copy="#selektor"].
// clipboard API wymaga secure context (https/localhost), a aplikacja bywa serwowana
// po http://<ip> — dlatego jest awaryjne przejście na zaznaczenie + execCommand.
document.addEventListener('click', function (e) {
const btn = e.target.closest('[data-copy]');
if (!btn) return;
const src = document.querySelector(btn.getAttribute('data-copy'));
if (!src) return;
const done = ok => {
const label = btn.textContent;
btn.textContent = ok ? 'Skopiowano ✓' : 'Nie udało się — zaznacz i skopiuj ręcznie';
setTimeout(() => { btn.textContent = label; }, 2500);
};
const text = src.value !== undefined ? src.value : src.textContent;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => done(true), () => done(false));
return;
}
// awaryjnie: zaznacz zawartość i spróbuj starym poleceniem
try {
src.focus();
if (src.select) src.select();
done(document.execCommand('copy'));
} catch (err) {
done(false);
}
});
+116
View File
@@ -0,0 +1,116 @@
// Wyszukiwarka lokalizacji + mapa (OpenStreetMap / Leaflet, bez klucza API).
// Wpina się do formularza z polami input[name=lat] / input[name=lon]:
// - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/lon,
// - klik na mapie lub przeciągnięcie pineski -> ustawia lat/lon + /reverse pokazuje nazwę,
// - „Tu i teraz" (now.js) emituje event 'astrololo:coords' -> mapa się synchronizuje.
// Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak.
(function () {
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
var picker = document.getElementById('geoPicker');
if (!picker || typeof L === 'undefined') return; // brak Leafleta = po cichu pas
var latEl = document.querySelector('input[name=lat]');
var lonEl = document.querySelector('input[name=lon]');
if (!latEl || !lonEl) return;
var searchEl = document.getElementById('geoSearch');
var searchBtn = document.getElementById('geoSearchBtn');
var mapToggle = document.getElementById('geoMapToggle');
var resultsEl = document.getElementById('geoResults');
var mapEl = document.getElementById('map');
var num = function (el) { var v = parseFloat(el.value); return isNaN(v) ? 0 : v; };
var curLat = function () { return num(latEl); };
var curLon = function () { return num(lonEl); };
var pin = L.divIcon({ className: 'geo-pin', html: '📍', iconSize: [24, 24], iconAnchor: [12, 24] });
var map = L.map(mapEl).setView([curLat(), curLon()], 12);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
var marker = L.marker([curLat(), curLon()], { draggable: true, icon: pin }).addTo(map);
// Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka.
setTimeout(function () { map.invalidateSize(); }, 60);
function setCoords(lat, lon, fromMap) {
latEl.value = lat.toFixed(4);
lonEl.value = lon.toFixed(4);
marker.setLatLng([lat, lon]);
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
if (fromMap) reverseName(lat, lon);
}
function reverseName(lat, lon) {
fetch('/reverse?lat=' + lat + '&lon=' + lon)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.name) searchEl.value = d.name; })
.catch(function () {});
}
marker.on('dragend', function () {
var p = marker.getLatLng();
setCoords(p.lat, p.lng, true);
});
map.on('click', function (e) { setCoords(e.latlng.lat, e.latlng.lng, true); });
// ---- wyszukiwanie po nazwie ----
function renderResults(items) {
resultsEl.innerHTML = '';
if (!items.length) { resultsEl.hidden = true; return; }
items.forEach(function (it) {
var li = document.createElement('li');
li.textContent = it.name;
li.addEventListener('click', function () {
setCoords(it.lat, it.lon, false);
searchEl.value = it.name;
resultsEl.hidden = true;
});
resultsEl.appendChild(li);
});
resultsEl.hidden = false;
}
function doSearch() {
var q = searchEl.value.trim();
if (q.length < 3) { resultsEl.hidden = true; return; }
fetch('/geocode?q=' + encodeURIComponent(q))
.then(function (r) { return r.ok ? r.json() : { results: [] }; })
.then(function (d) { renderResults(d.results || []); })
.catch(function () { renderResults([]); });
}
var timer = null;
searchBtn.addEventListener('click', doSearch);
searchEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); doSearch(); } // nie wysyłaj formularza
});
searchEl.addEventListener('input', function () {
clearTimeout(timer); timer = setTimeout(doSearch, 500); // debounce (limit Nominatim)
});
// ---- zwijanie/rozwijanie mapy ----
mapToggle.addEventListener('click', function () {
var hidden = mapEl.hasAttribute('hidden');
if (hidden) {
mapEl.removeAttribute('hidden');
mapToggle.textContent = 'Ukryj mapę';
setTimeout(function () { map.invalidateSize(); map.setView([curLat(), curLon()]); }, 60);
} else {
mapEl.setAttribute('hidden', '');
mapToggle.textContent = 'Pokaż mapę';
}
});
// ---- synchronizacja z „Tu i teraz" ----
document.addEventListener('astrololo:coords', function () {
marker.setLatLng([curLat(), curLon()]);
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
});
});
})();
@@ -0,0 +1,58 @@
// Podpowiedzi modeli zależne od wybranego dostawcy.
//
// Pole modelu jest CELOWO tekstowe (input + datalist), a nie zamkniętym <select>:
// katalog to tylko wygoda, a konto może mieć dostęp do modeli, o których kod nie
// wie. Puste pole = model domyślny dostawcy.
document.addEventListener('DOMContentLoaded', function () {
const providerEl = document.getElementById('llmProvider');
const modelEl = document.getElementById('llmModel');
const listEl = document.getElementById('llmModelList');
const hintEl = document.getElementById('llmModelHint');
const dataEl = document.getElementById('llmCatalog');
if (!providerEl || !modelEl || !listEl || !dataEl) return;
let catalog = {};
let defaults = {};
try {
const parsed = JSON.parse(dataEl.textContent || '{}');
catalog = parsed.providers || {};
defaults = parsed.defaults || {};
} catch (e) {
return; // brak katalogu — pole nadal działa jako wolny tekst
}
const fmt = n =>
n >= 1000000 ? (n / 1000000) + 'M' : (n >= 1000 ? Math.round(n / 1000) + 'k' : String(n));
function refresh(resetValue) {
const provider = providerEl.value;
const models = catalog[provider] || [];
listEl.innerHTML = '';
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.label = m.label + ' · kontekst ' + fmt(m.context_window);
listEl.appendChild(opt);
});
const fallback = defaults[provider] || '';
modelEl.placeholder = fallback ? 'domyślny: ' + fallback : 'domyślny dostawcy';
// po zmianie dostawcy stary model nie ma sensu (np. gpt-4o u Anthropica)
if (resetValue) modelEl.value = '';
if (hintEl) {
const chosen = models.find(m => m.id === modelEl.value.trim());
hintEl.textContent = chosen
? chosen.label + ' — okno kontekstu ' + fmt(chosen.context_window) +
' tokenów, maksymalna odpowiedź ' + fmt(chosen.max_output) + '.'
: 'Zostaw puste, by użyć modelu domyślnego. Możesz też wpisać dowolny ' +
'identyfikator modelu, do którego Twoje konto ma dostęp.';
}
}
providerEl.addEventListener('change', () => refresh(true));
modelEl.addEventListener('input', () => refresh(false));
refresh(false);
});
+1
View File
@@ -32,6 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
function (pos) {
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
document.dispatchEvent(new CustomEvent('astrololo:coords')); // zsynchronizuj mapę (geo.js)
say('Lokalizacja pobrana ✓');
},
function (err) {
@@ -64,3 +64,27 @@ tr:last-child td { border-bottom: none; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
/* Generator promptu do LLM (LOG-29/30) */
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
background: #12132a; color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
{# Wyszukiwarka lokalizacji + interaktywna mapa (OpenStreetMap / Leaflet, bez klucza API).
Wklejana do formularzy z polami input[name=lat] / input[name=lon]. Samowystarczalna:
ładuje CSS/JS Leafleta (vendorowane lokalnie) oraz geo.js. Kafelki mapy lecą z OSM. #}
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
<div class="geo" id="geoPicker">
<div class="geo-search">
<input type="search" id="geoSearch" autocomplete="off"
placeholder="Szukaj miejsca (np. Szpital Barlickiego, Łódź)…">
<button type="button" id="geoSearchBtn" class="ghost">Szukaj</button>
<button type="button" id="geoMapToggle" class="ghost">Ukryj mapę</button>
</div>
<ul id="geoResults" class="geo-results" hidden></ul>
<div id="map" class="geo-map"></div>
<p class="muted small geo-attrib">
Wyszukiwarka i mapa: © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>
contributors (Nominatim). Kliknij mapę lub przeciągnij pineskę, by ustawić współrzędne.
</p>
</div>
<script defer src="/static/vendor/leaflet/leaflet.js"></script>
<script defer src="/static/geo.js"></script>
@@ -0,0 +1,115 @@
{# Katalog modeli wstrzykiwany przez handler — pole jest tekstowe, więc
można wpisać dowolny model, do którego konto ma dostęp. #}
<script type="application/json" id="llmCatalog">{{ llm_catalog | tojson }}</script>
{# Wspólny blok: sterowanie generowaniem + prompt (LOG-29/30) + horoskop (LOG-31).
Używany na ekranach Interpretacje i Kalendarz. #}
<div class="opts">
<label>Budżet promptu
<select name="prompt_budget">
{% set pb = form.prompt_budget or 'medium' %}
<option value="concise" {{ 'selected' if pb == 'concise' else '' }}>zwięzły (~4 tys. znaków)</option>
<option value="medium" {{ 'selected' if pb == 'medium' else '' }}>średni (~12 tys.)</option>
<option value="extensive" {{ 'selected' if pb == 'extensive' else '' }}>obszerny (~30 tys.)</option>
<option value="huge" {{ 'selected' if pb == 'huge' else '' }}>bardzo obszerny (~120 tys.)</option>
<option value="max" {{ 'selected' if pb == 'max' else '' }}>maksymalny kontekst modelu</option>
</select>
</label>
<label>Dostawca
<select name="llm_provider" id="llmProvider">
{% set lp = form.llm_provider or 'local' %}
<option value="local" {{ 'selected' if lp == 'local' else '' }}>lokalny — nic nie opuszcza sieci</option>
<option value="anthropic" {{ 'selected' if lp == 'anthropic' else '' }}>Anthropic — dane wychodzą</option>
<option value="openai" {{ 'selected' if lp == 'openai' else '' }}>OpenAI — dane wychodzą</option>
</select>
</label>
<label>Model
<input type="text" name="llm_model" id="llmModel" list="llmModelList"
value="{{ form.llm_model or '' }}" placeholder="domyślny dostawcy"
autocomplete="off">
<datalist id="llmModelList"></datalist>
</label>
</div>
<p class="muted small" id="llmModelHint"></p>
<p class="muted small">
Prompt zawiera <strong>oryginalne opisy z baz</strong> oraz dane urodzeniowe. Model lokalny
przetwarza je u nas; wybór dostawcy w chmurze oznacza, że ta treść <strong>opuszcza naszą
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
</p>
{% if prompt_result %}
{% set st = prompt_result.stats %}
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
{% if prompt_result.horoscope %}
<div class="meta">
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
model: {{ prompt_result.model }}
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
{% endif %}
{% if prompt_result.leaves_lan %}
· <strong class="retro">dane opuściły sieć</strong>
{% else %}
· dane nie opuściły sieci
{% endif %}
</div>
<div class="actions">
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
</div>
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
<p class="muted small">
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
</p>
{% endif %}
{% if prompt_result.warnings %}
{% for w in prompt_result.warnings %}
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
{% endfor %}
{% endif %}
{% if prompt_result.token_plan %}
{% set tp = prompt_result.token_plan %}
<p class="muted small">
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
zarezerwowane na odpowiedź {{ tp.max_output }}
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
{% endif %}
</p>
{% endif %}
{% if prompt_result.llm_error %}
<div class="error">
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
</div>
{% endif %}
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
<div class="meta">
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
budżet: {{ st.budget }} ·
wskazań: <strong>{{ st.included }}</strong>
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
</div>
{% if st.omitted %}
<p class="muted small">
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
</p>
{% endif %}
{% if prompt_result.data_error %}
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
{% endif %}
<div class="actions">
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
</div>
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
{% endif %}
@@ -13,6 +13,7 @@
<nav>
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
</nav>
</header>
+48 -2
View File
@@ -32,7 +32,23 @@
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
</select>
</label>
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
<option value="tropical" {{ 'selected' if zd == 'tropical' else '' }}>Tropikalny</option>
<option value="sidereal_lahiri" {{ 'selected' if zd == 'sidereal_lahiri' else '' }}>Syderyczny (Lahiri)</option>
<option value="sidereal_fagan_bradley" {{ 'selected' if zd == 'sidereal_fagan_bradley' else '' }}>Syderyczny (Fagan-Bradley)</option>
<option value="sidereal_krishnamurti" {{ 'selected' if zd == 'sidereal_krishnamurti' else '' }}>Syderyczny (Krishnamurti)</option>
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
</div>
{% include "_location_picker.html" %}
<div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label>
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit">Policz horoskop</button>
@@ -49,6 +65,7 @@
Silnik: <strong>{{ result.engine }}</strong> ·
obiektów: {{ result.positions | length }}
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
{% if result.zodiac %}· zodiak: {{ result.zodiac }}{% if result.ayanamsha is defined %} (ayanamsa {{ '%.4f'|format(result.ayanamsha) }}°){% endif %}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %}
</div>
@@ -84,13 +101,42 @@
</tbody>
</table>
{% if result.lots %}
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
<table class="angles">
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
<tbody>
{% for l in result.lots %}
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
{% if with_stations %}
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
<table class="angles">
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
<tbody>
{% for p in with_stations %}
<tr>
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.aspects %}
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody>
{% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td></tr>
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
@@ -23,12 +23,17 @@
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
</div>
{% include "_location_picker.html" %}
<div class="opts">
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
{% include "_prompt_block.html" %}
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit">Szukaj interpretacji</button>
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
<span id="geoNote" class="muted small"></span>
</div>
</form>
@@ -84,4 +89,6 @@
{% endif %}
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
{% endblock %}
@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Kalendarz{% endblock %}
{% block nav_timeline %}active{% endblock %}
{% block content %}
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
<form method="post" action="/timeline">
<div class="grid">
<label>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>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
<label>Szerokość (lat)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
</label>
<label>Długość (lon)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
{% include "_location_picker.html" %}
<div class="grid">
<label>Zakres od
<input type="date" name="from_date" value="{{ form.from_date or '' }}" required>
</label>
<label>Zakres do
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
</label>
</div>
{% include "_prompt_block.html" %}
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
<span id="geoNote" class="muted small"></span>
</div>
</form>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% if result %}
{% if result.data_error %}<div class="error">{{ result.data_error }}</div>{% endif %}
<div class="meta">
Silnik: <strong>{{ result.engine }}</strong> ·
zdarzeń: {{ result.count }} · zakres {{ result.from }} → {{ result.to }}
</div>
{% for e in result.events %}
<div class="sig-item">
<div class="sig-head">
<span class="badge">{{ e.exact }}</span>
<span class="badge">{{ e.technique }}</span>
<strong>{{ e.significator }}</strong>
<span class="muted small">(okno {{ e.start }} → {{ e.end }})</span>
</div>
{% if e.interpretations %}
<table class="samples">
<tbody>
{% for s in e.interpretations %}
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
{% endfor %}
</tbody>
</table>
{% if e.interpretations_count > e.interpretations | length %}
<div class="muted small">…i {{ e.interpretations_count - (e.interpretations | length) }} więcej</div>
{% endif %}
{% elif e.technique != 'solar_return' %}
<div class="muted small">brak dopasowań w bazie</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
{% endblock %}
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0
+2
View File
@@ -3,3 +3,5 @@ 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
@@ -0,0 +1,155 @@
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32)
oraz klucz szyfrujący łącze (PRE-16).
Powód istnienia tego testu: token dodano do klienta na gałęzi, która odbiła się od
mastera zanim powstały metody `prompt()` i `horoscope()`. Git zmergował obie zmiany
czysto (różne linie), ale nowe metody wyszły BEZ tokenu — i dostawały 401 dopiero na
produkcji. Zwykły test jednej metody by tego nie złapał, więc sprawdzamy regułę
strukturalnie.
Po dołożeniu szyfrowania ta sama klasa błędu ma gorszy objaw: wywołanie bez `link=`
nie wywala się widocznie, tylko po cichu wysyła treść JAWNYM tekstem. Dlatego dla
wywołań przez `link_crypto` wymagamy obu argumentów naraz.
"""
import ast
import pathlib
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
HTTP_VERBS = ("post", "get", "put", "patch", "delete")
LINK_CALLS = ("call", "call_json")
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool, bool]]:
"""(opis, linia, czy_ma_headers, czy_wymaga_i_ma_link) dla każdego wyjścia w dół."""
tree = ast.parse(path.read_text(encoding="utf-8"))
out = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
target = node.func.value
has_headers = any(kw.arg == "headers" for kw in node.keywords)
has_link = any(kw.arg == "link" for kw in node.keywords)
if (node.func.attr in HTTP_VERBS
and isinstance(target, ast.Name) and target.id == "client"):
# Surowe wywołanie httpx — omija szyfrowanie, więc dopuszczalne tylko
# dla ścieżek wyjętych spod niego (patrz link_crypto.PUBLIC_PATHS).
out.append((f"client.{node.func.attr}()", node.lineno, has_headers, True))
elif (node.func.attr in LINK_CALLS
and isinstance(target, ast.Name) and target.id == "link_crypto"):
out.append((f"link_crypto.{node.func.attr}()", node.lineno, has_headers, has_link))
return out
def test_client_module_exists():
assert CLIENT.is_file()
def test_every_outbound_call_sends_auth_header():
calls = _http_calls(CLIENT)
assert calls, "nie znaleziono żadnego wywołania HTTP — test przestał cokolwiek pilnować"
missing = [f"{CLIENT.name}:{line} {what}" for what, line, ok, _ in calls if not ok]
assert not missing, (
"Wywołania w dół bez tokenu międzywarstwowego (dostaną 401 przy włączonej "
"ochronie): " + ", ".join(missing)
)
def test_every_outbound_call_passes_link_key():
"""Brak `link=` nie boli od razu — po prostu treść leci jawnym tekstem."""
calls = _http_calls(CLIENT)
missing = [f"{CLIENT.name}:{line} {what}" for what, line, _, ok in calls if not ok]
assert not missing, (
"Wywołania w dół bez klucza łącza — poszłyby NIEZASZYFROWANE: " + ", ".join(missing)
)
def test_raw_http_calls_only_on_paths_exempt_from_encryption():
"""Surowe `client.get/post` wolno wołać wyłącznie tam, gdzie szyfrowania nie ma
z założenia (`/health` dla sond k8s). Każde inne to obejście PRE-16."""
import re
from app import link_crypto
source = CLIENT.read_text(encoding="utf-8").splitlines()
offenders = []
for what, line, _, _ in _http_calls(CLIENT):
if not what.startswith("client."):
continue
url = re.search(r'f"\{self\.base_url\}([^"]*)"', source[line - 1])
if url is None or url.group(1) not in link_crypto.PUBLIC_PATHS:
offenders.append(f"{CLIENT.name}:{line} {what}")
assert not offenders, (
"Surowe wywołania HTTP poza ścieżkami wyjętymi spod szyfrowania: "
+ ", ".join(offenders)
)
def test_auth_headers_helper_is_lazy():
"""Token czytany przy wywołaniu, nie przy imporcie — inaczej pod wystartowałby
z pustym tokenem, gdyby zmienna pojawiła się później."""
import os
from app.clients.logic_client import _auth_headers
old = os.environ.get("INTERNAL_TOKEN")
try:
os.environ["INTERNAL_TOKEN"] = "abc"
assert _auth_headers() == {"X-Astrololo-Token": "abc"}
os.environ.pop("INTERNAL_TOKEN")
assert _auth_headers() == {} # ochrona wyłączona = brak nagłówka
finally:
if old is not None:
os.environ["INTERNAL_TOKEN"] = old
else:
os.environ.pop("INTERNAL_TOKEN", None)
# --------------------------------------------- wybor modelu musi dojsc do logiki
# Ta sama klasa bledu co przy tokenie: dokladajac nowa sciezke latwo zapomniec
# przekazac parametr, a objaw (cichy powrot do modelu domyslnego) jest niewidoczny.
def _calls_to(path_fragment: str) -> list[int]:
"""Linie wywolan logic.<metoda>(...) w handlerach prezentacji."""
main = CLIENT.parent.parent / "main.py"
tree = ast.parse(main.read_text(encoding="utf-8"))
out = []
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == path_fragment
and isinstance(node.func.value, ast.Name) and node.func.value.id == "logic"):
out.append(node.lineno)
return out
def _has_kwarg(main_src: str, lineno: int, name: str) -> bool:
tree = ast.parse(main_src)
for node in ast.walk(tree):
if isinstance(node, ast.Call) and node.lineno == lineno:
return any(kw.arg == name for kw in node.keywords)
return False
def test_every_llm_call_passes_selected_model():
main = CLIENT.parent.parent / "main.py"
src = main.read_text(encoding="utf-8")
missing = []
for method in ("prompt", "horoscope"):
for line in _calls_to(method):
if not _has_kwarg(src, line, "model"):
missing.append(f"main.py:{line} logic.{method}()")
assert not missing, (
"Wywołania bez wybranego modelu — po cichu użyją domyślnego: " + ", ".join(missing)
)
def test_every_llm_call_passes_provider():
main = CLIENT.parent.parent / "main.py"
src = main.read_text(encoding="utf-8")
missing = []
for method in ("prompt", "horoscope"):
for line in _calls_to(method):
if not _has_kwarg(src, line, "provider"):
missing.append(f"main.py:{line} logic.{method}()")
assert not missing, "Wywołania bez dostawcy: " + ", ".join(missing)
@@ -0,0 +1,201 @@
"""Logowanie do aplikacji i limit żądań (LOG-32).
To jest brama chroniąca oryginalne bazy interpretacyjne — bez niej każdy w sieci
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
"""
import base64
import pytest
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from starlette.testclient import TestClient
from app import security
USER, PASSWORD = "astrololo", "haslo-testowe"
def _basic(user: str, password: str) -> dict[str, str]:
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
def _app() -> FastAPI:
app = FastAPI()
security.install(app)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/significators", response_class=HTMLResponse)
def significators():
return "<p>treść z bazy interpretacyjnej</p>"
return app
@pytest.fixture(autouse=True)
def _reset_rate_limit():
security._hits.clear()
yield
security._hits.clear()
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
return TestClient(_app())
# ------------------------------------------------------------------ logowanie
def test_blocks_anonymous_access(guarded):
r = guarded.get("/significators")
assert r.status_code == 401
assert "treść z bazy" not in r.text
def test_challenges_with_basic_realm(guarded):
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania."""
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "")
def test_rejects_wrong_password(guarded):
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401
def test_rejects_wrong_user(guarded):
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401
def test_rejects_malformed_header(guarded):
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"):
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
def test_allows_correct_credentials(guarded):
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
assert r.status_code == 200
assert "treść z bazy" in r.text
def test_health_stays_public(guarded):
assert guarded.get("/health").status_code == 200
def test_open_when_password_unset(monkeypatch):
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie)."""
monkeypatch.delenv("APP_PASSWORD", raising=False)
assert not security.auth_enabled()
assert TestClient(_app()).get("/significators").status_code == 200
# --------------------------------------------------------------- limit żądań
def test_rate_limit_blocks_flood(monkeypatch):
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
assert codes[:5] == [200] * 5
assert 429 in codes[5:]
def test_rate_limited_response_has_retry_after(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
client.get("/significators", headers=auth)
r = client.get("/significators", headers=auth)
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
def test_rate_limit_precedes_auth(monkeypatch):
"""Limit musi działać także dla niezalogowanych — inaczej zgadywanie hasła
i sondowanie API jest darmowe."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
client = TestClient(_app())
codes = [client.get("/significators").status_code for _ in range(6)]
assert 429 in codes
def test_rate_limit_disabled_when_zero(monkeypatch):
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
assert all(client.get("/significators").status_code == 200 for _ in range(30))
# ------------------------------------------ adres klienta za odwrotnym proxy
#
# Po włączeniu TLS (PRE-16) aplikacja stoi za Ingressem, więc bezpośredni peer
# to zawsze POD proxy. Te testy pilnują obu stron kompromisu: żeby limit dalej
# rozróżniał ludzi, a jednocześnie żeby nagłówek nie stał się furtką do jego
# ominięcia.
class _Req:
"""Minimalny zamiennik Request — `client_ip` czyta tylko te dwa pola."""
def __init__(self, peer: str | None, **headers: str):
self.client = type("C", (), {"host": peer})() if peer else None
self.headers = {k.replace("_", "-"): v for k, v in headers.items()}
def test_client_ip_ignores_headers_without_trust_proxy(monkeypatch):
"""Bez TRUST_PROXY nagłówek jest bezwartościowy — każdy może go dopisać."""
monkeypatch.delenv("TRUST_PROXY", raising=False)
req = _Req("10.42.0.7", x_forwarded_for="1.2.3.4", x_real_ip="5.6.7.8")
assert security.client_ip(req) == "10.42.0.7"
def test_client_ip_takes_last_forwarded_entry(monkeypatch):
"""Ostatni wpis dopisał NASZ proxy; wcześniejsze mógł podstawić klient."""
monkeypatch.setenv("TRUST_PROXY", "true")
req = _Req("10.42.0.7", x_forwarded_for="1.1.1.1, 2.2.2.2, 192.168.1.50")
assert security.client_ip(req) == "192.168.1.50"
def test_client_ip_falls_back_to_real_ip(monkeypatch):
monkeypatch.setenv("TRUST_PROXY", "true")
req = _Req("10.42.0.7", x_real_ip="192.168.1.50")
assert security.client_ip(req) == "192.168.1.50"
def test_client_ip_falls_back_to_peer_when_headers_missing(monkeypatch):
monkeypatch.setenv("TRUST_PROXY", "true")
assert security.client_ip(_Req("10.42.0.7")) == "10.42.0.7"
def test_spoofed_forwarded_header_cannot_dodge_the_limit(monkeypatch):
"""Sedno sprawy: bez zaufania do proxy podszywanie się NIE resetuje wiadra.
Gdyby limiter brał pierwszy lepszy `X-Forwarded-For`, wystarczyłoby zmieniać
go co żądanie, żeby pompować bazę bez ograniczeń.
"""
monkeypatch.delenv("TRUST_PROXY", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
codes = [client.get("/significators", headers={"X-Forwarded-For": f"9.9.9.{i}"}).status_code
for i in range(6)]
assert 429 in codes
def test_proxied_clients_get_separate_buckets(monkeypatch):
"""Za proxy dwie różne osoby nie mogą się nawzajem odcinać."""
monkeypatch.setenv("TRUST_PROXY", "true")
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
first = [client.get("/significators", headers={"X-Forwarded-For": "192.168.1.50"}).status_code
for _ in range(5)]
second = client.get("/significators", headers={"X-Forwarded-For": "192.168.1.51"})
assert 429 in first, "limit musi zadziałać dla pierwszego adresu"
assert second.status_code == 200, "drugi adres ma własne wiadro"
View File