Compare commits

...

56 Commits

Author SHA1 Message Date
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
gitea 4a86d34f5a Geolokalizacja: jawny komunikat gdy brak secure context (http://)
Przyczyna "nie pyta o zgodę": navigator.geolocation działa tylko w secure
context (https:// lub localhost). Na http://<ip> przeglądarka po cichu
odmawia — bez promptu; kod nie miał callbacku błędu, więc nic nie było widać.

- wspólny static/now.js (deduplikacja skryptu z chart.html i interpret.html)
- jawna detekcja window.isSecureContext + czytelny komunikat w #geoNote
  ("wymaga HTTPS lub localhost — wpisz lat/lon ręcznie")
- callback błędu (odmowa/timeout) też widoczny; status "Pobieram lokalizację…"
  i potwierdzenie po sukcesie

Zweryfikowano: /static/now.js serwowany (200), obie strony referencjonują
skrypt i mają #geoNote.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:59:03 +02:00
gitea fb317172ff Merge pull request #14 from migatu/feat/scoring-grouping-geo
Ranking sily (LOG-21) + grupowanie identycznych opisow + geolokalizacja
2026-07-06 19:29:56 +02:00
gitea f6323cac10 Ranking siły (LOG-21) + grupowanie identycznych opisów + geolokalizacja
Punktacja siły (LOG-21):
- scoring.py: siła fasety z sygnałów obliczalnych (typ fasety, rodzaj aspektu,
  ciasnota orbu). Konfigurowalne wagi. Hook na przyszłość: kolumny countas*/level*
  z SIGNIFICATORS KEY (obecnie puste).
- aspekty niosą orb+allowed; fasety dostają "score"; ranking faset malejąco.

Grupowanie:
- opcja group: zwija próbki po opisie (ten sam efekt = jedna grupa z listą
  sygnifikatorów i licznikiem). Checkbox "grupuj identyczne opisy" w /interpret.

Geolokalizacja (bajer):
- "Tu i teraz" (widok Horoskop i Interpretacje) uzupełnia lat/lon z przeglądarki
  (navigator.geolocation; wymaga zgody, https/localhost).

Zweryfikowano na realnym main_base.xlsx: ranking sensowny (ciasna opozycja z
Saturn 9.59 > szeroka koniunkcja z Moon 6.25 > znak/dom 5.0); grupowanie zwija
powtórzone opisy. 41 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:16:47 +02:00
gitea 243d02d55b Merge pull request #13 from migatu/feat/aspects
Aspekty (LOG-06) + faseta aspektu, dedup i dopieszczenie wynikow
2026-07-02 10:18:59 +02:00
gitea 166b438f83 Aspekty (LOG-06) + faseta aspektu, dedup i dopieszczenie wyników
Aspekty:
- engine/aspects.py: aspekty główne (conj/sex/sq/tri/opp) z orbami
  (bonus dla luminarzy), separacja z obsługą zawinięcia. Applying/sep na później.
- build_chart zwraca listę aspektów; /chart/positions je udostępnia;
  widok Horoskop pokazuje tabelę aspektów.

Bogatsze sygnifikatory:
- trzecia faseta "w aspekcie": dla każdego aspektu głównego obiektu filtruje
  rekordy po tokenie aspektu + drugiej planety ([conj + [Mo). Cookbook
  komplet: znak + dom + aspekt.

Dopieszczenie wyników:
- ODSIEWANIE DUPLIKATÓW: duplikat = ten sam sygnifikator ORAZ ten sam opis
  (po normalizacji). Dedup wewnątrz fasety, działa też na wynikach z wielu baz.
- _facet_samples przyjmuje wiele tokenów (AND); dedup + istniejące odsiewanie szumu.

Zweryfikowano na realnym main_base.xlsx (30.04.1984): 16 aspektów zgodnych z
astro.com (Sun conj Moon 9.59°, Sun opp Saturn 3.17°); faseta aspektu daje
bogate trafienia (Sun koniunkcja z Moon 84, opozycja z Saturn 43); dedup obniżył
duplikaty (Sun w znaku 46->44). 36 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:55:27 +02:00
gitea 41d7ca491d Merge pull request #12 from migatu/feat/noise-and-abbrev
Odsiewanie szumu + rozwijanie skrótów sygnifikatorów
2026-07-01 16:01:02 +02:00
gitea 6acd3546fb Merge pull request #10 from migatu/feat/richer-significators
Bogatsze sygnifikatory: faseta w domu obok w znaku (LOG-15/16)
2026-07-01 15:58:08 +02:00
gitea 82809665ff Odsiewanie szumu + rozwijanie skrótów sygnifikatorów
- abbreviations.py: słownik skrót -> pełna nazwa (z SIGNIFICATORS KEY, built-in)
  + expand(): [Su in [Tau -> "Sun in Taurus", [Sa in 6th H. -> "...6th house",
  affl. -> afflicted itd.
- significators: każda próbka ma pole "expanded" (postać czytelna); hartowanie
  filtra szumu (efekty zastępcze x/?/-, wiersze *MARKER, legendy/nagłówki).
- prezentacja /interpret: pokazuje rozwiniętą postać, surowy skrót w tooltipie.

Zweryfikowano na realnym main_base.xlsx: "[Sa or [Ma in the 5th H." ->
"Saturn or Mars in the 5th house". 9 testów przechodzi (w tym test_abbreviations).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:42:47 +02:00
gitea 0e74566a78 Bogatsze sygnifikatory: faseta "w domu" obok "w znaku" (LOG-15/16)
Most horoskop -> sygnifikatory generuje teraz dla każdego obiektu dwie fasety:
- "w znaku": planeta + token znaku ([Su + [Tau)
- "w domu":  planeta + token domu ([Su + 11th H.) — używa domów z LOG-05

- significators.build_report: przyjmuje pozycje z build_chart (z numerami domów),
  generuje fasety znak/dom, filtruje szum. Ordinal helper (1st..12th).
- /chart/report: używa build_chart (pozycje z domami).
- Prezentacja /interpret: render faset (znak/dom) per obiekt.

Aspekty ([conj/[sq/[opp) na później — wymagają policzenia aspektów (LOG-06).

Zweryfikowano na realnym main_base.xlsx (53969 wierszy), 30.04.1984:
Mars w 5. domu 10 dopasowań ("[Sa or [Ma in the 5th H." -> "abortion/miscarriage"),
Neptune w 7. domu 6, Uranus w 6. domu 4. 7 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:37:20 +02:00
gitea cea6907f84 Merge pull request #9 from migatu/feat/search-integration
Wyszukiwarka: wynik obliczen szukany w bazie interpretacji
2026-07-01 15:03:29 +02:00
gitea dabbaef9dd Merge branch 'master' into feat/search-integration 2026-07-01 15:03:12 +02:00
gitea a81ae698a3 Merge pull request #8 from migatu/feat/engine-houses
Silnik: osie (Asc/MC) i systemy domow (LOG-05)
2026-07-01 15:01:01 +02:00
gitea eef67d37b5 Wyszukiwarka: wynik obliczeń szukany w bazie interpretacji
Pierwsza wersja mostu horoskop -> sygnifikatory -> baza (zalążek LOG-16/18/19).

- logic/significators.py: z pozycji generuje tokeny w składni bazy (planeta
  [Su, znak [Tau...), pyta warstwę danych o rekordy z tokenem planety i zawęża
  do tych, które wspominają też jej znak ("planeta w swoim znaku"); odsiewa szum.
- logic /chart/report: nowy endpoint (pozycje -> raport dopasowań z interpretacjami).
- logic DataClient.search: parametr fields (lżejszy payload).
- data: naprawa str.contains regex=True -> regex=False (sygnifikatory zawierają
  [ + itd., metaznaki regex); podniesiony górny limit zapytania (le=50000).
- prezentacja: strona /interpret (formularz -> wyszukane interpretacje per obiekt)
  + nawigacja.

Zweryfikowano end-to-end na realnym pliku (Encyclopaedia of Medical Astrology,
53969 wierszy): dla horoskopu 30.04.1984 znaleziono m.in. Sun w Taurus 46,
Mars w Scorpio 57, Saturn w Scorpio 61 dopasowań; przykłady: "[Su in [Tau" ->
"the bump of amativeness prominent". 15 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:57:26 +02:00
gitea 94c3023d3a Silnik: osie (Asc/MC) i systemy domów (LOG-05)
Kontynuacja silnika efemeryd o osie i domy.

- engine/houses.py: czysta matematyka sferyczna — Asc, MC (z RAMC + ε + φ),
  cusps dla Whole Sign / Equal / Porphyry, przypisanie obiektu do domu.
- SkyfieldEngine.sidereal(): RAMC (lokalny apparent ST) + średnie nachylenie
  ekliptyki ze Skyfielda.
- engine/chart.py: build_chart() składa pełny horoskop (pozycje + osie + domy).
- Endpoint /chart/positions rozszerzony o house_system i zwraca angles + cusps
  + numer domu per obiekt.
- Prezentacja: lokalizacja i wybór systemu domów w formularzu, tabela osi,
  kolumna Dom, rozwijane cusps.

Walidacja względem astro.com (30.04.1984, Warszawa): Asc Can 22°10'43",
MC Pis 22°35'29" (~1' od referencji); wszystkie przypisania domów Whole Sign
zgodne (Sun 11, Mercury 10, Mars 5, ...). 20 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:06:13 +02:00
gitea e4f6a16c9c Merge pull request #7 from migatu/fix/local-dev-env
Napraw lokalne srodowisko dev: Parquet mixed-types, brak deps, compose
2026-07-01 11:24:15 +02:00
gitea 413c46b5dd Napraw lokalne środowisko dev: Parquet mixed-types, brak deps, compose
Trzy usterki uniemożliwiające uruchomienie stosu lokalnie:

1. Warstwa danych wykładała się na starcie przy zapisie Parquet dla realnych
   plików (np. Encyclopaedia of Medical Astrology) — kolumny o mieszanych
   typach (int+str+NaN). frame_cache.put() zapisuje teraz ramkę jako string
   (warstwa i tak wyszukuje po tekście). Dodatkowo warmup() jest odporny:
   pojedynczy uszkodzony plik nie blokuje startu usługi.

2. Brakowało kroku instalacji zależności — dev-logic/dev-presentation padały na
   'No module named httpx'. Nowy cel `make install` instaluje zależności
   WSZYSTKICH warstw do aktywnego venv. README zaktualizowane (instalowało
   wcześniej tylko warstwę danych).

3. `make up` zakładał `docker compose`, którego użytkownik nie ma. Makefile
   wykrywa `docker compose` lub `docker-compose`, a przy braku obu podaje
   czytelną instrukcję trybu lokalnego. Dodano `make test` i `make clean-cache`.

Zweryfikowane end-to-end na realnym środowisku (.env, Python 3.14) i danych:
warmup przechodzi (3 pliki), cały stos wstaje, formularz → logika → silnik
Skyfield zwraca poprawne pozycje.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:55:04 +02:00
gitea 340b3058e4 Merge pull request #6 from migatu/feat/presentation-chart
Warstwa prezentacji: widok horoskopu do recznego testowania
2026-06-30 20:33:31 +02:00
gitea 376a4bfded Merge pull request #5 from migatu/feat/logic-engine
Silnik efemeryd: EngineProvider + SkyfieldEngine + harness porównawczy (LOG-24/01/25/27/28)
2026-06-30 20:26:36 +02:00
gitea 3195d9b003 Warstwa prezentacji: widok horoskopu do ręcznego testowania
Strona główna "/" = formularz podstawowych danych momentu (data, godzina,
strefa, opcjonalnie lokalizacja) → tabela policzonych pozycji w formie
human-readable (znak, pozycja w znaku, absolutna, kierunek, prędkość).
Woła logic /chart/positions; przelicza czas lokalny + offset na UTC.
Przycisk "Tu i teraz" uzupełnia bieżącą datę/godzinę i strefę przeglądarki.
Retrogradacja wyróżniona w tabeli.

Wyszukiwarkę sygnifikatorów przeniesiono pod "/significants" -> /significators,
dodano nawigację (base.html). Czytelny komunikat, gdy logika nie ma jeszcze
endpointu silnika.

Zweryfikowano end-to-end: formularz → przeliczenie UTC → render tabeli
(przez stub kontraktu /chart/positions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:08:43 +02:00
gitea e69c0714b9 Silnik efemeryd: EngineProvider + SkyfieldEngine + harness porównawczy
Pierwszy increment implementacji warstwy logicznej (ścieżka A).

LOG-24: interfejs EphemerisEngine z dwoma backendami — SkyfieldEngine
  (własny, permisywny: Skyfield MIT + dane JPL public domain) oraz
  RemoteEngine (klient izolowanej usługi swisseph). Fabryka + leniwa
  inicjalizacja; endpointy /chart/positions i /chart/compare.
LOG-01: pozycje obiektów (długość/szerokość ekliptyczna, prędkość,
  kierunek, formaty: w znaku / absolutny / dziesiętny).
LOG-25/28: harness porównawczy (compare.py) z progami tolerancji oraz
  wspólny kontrakt parzystości; pełen zestaw testów.
LOG-27: services/engine-swisseph — osobna, opcjonalna usługa AGPL
  (pyswisseph, tryb Moshiera), licencjonowana osobno, w compose pod
  profilem "comparison"; nie wchodzi do zamkniętego produktu.

Walidacja: SkyfieldEngine zgadza się ze Swiss Ephemeris co do ~1" dla
wszystkich 10 obiektów na horoskopie referencyjnym (30.04.1984, Warszawa);
12 testów przechodzi (silnik B pomijany gdy nieskonfigurowany).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:48:31 +02:00
gitea 9bf297d461 Merge pull request #4 from migatu/docs/plain-language-column
Wymagania: nietechniczna kolumna wyjaśnień (Po ludzku, bez żargonu)
2026-06-27 18:22:03 +02:00
98 changed files with 6860 additions and 104 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"
+116
View File
@@ -0,0 +1,116 @@
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
- name: Smoke test (health + pozycje)
run: |
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci
for i in $(seq 1 30); do
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://localhost:8003/health
echo
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
curl -fsS -X POST http://localhost:8003/positions \
-H 'Content-Type: application/json' \
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}'
echo
- name: Logi kontenera (gdy coś padło)
if: failure()
run: docker logs swe || 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
+4
View File
@@ -10,6 +10,10 @@ services/data/.cache/
*.parquet
*.db
# Cache efemeryd silnika własnego (pobierane jądra JPL, regenerowalne)
services/logic/.ephemeris/
*.bsp
# Dane wejściowe (duże pliki Excela trzymane poza repo)
services/data/data_files/*.xlsx
!services/data/data_files/.gitkeep
+60 -18
View File
@@ -1,31 +1,56 @@
.PHONY: help up down sample reindex migrate sql dev-data dev-logic dev-presentation
.PHONY: help install up down sample reindex migrate sql test clean-cache \
dev-data dev-logic dev-presentation
# Autodetekcja narzędzia compose: nowe "docker compose" albo stare "docker-compose".
COMPOSE := $(shell if docker compose version >/dev/null 2>&1; then echo "docker compose"; \
elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
# Python aktywnego środowiska (venv). Nadpisz: make install PY=python3.12
PY ?= python
help:
@echo "up - uruchom wszystkie 3 warstwy (docker compose)"
@echo "down - zatrzymaj"
@echo "sample - wygeneruj przykładowe pliki .xlsx"
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
@echo "migrate - ETL: Excel -> SQL"
@echo "sql - uruchom z warstwą SQL (DATA_PROVIDER=sql)"
@echo "dev-* - uruchom pojedynczą warstwę lokalnie (bez dockera)"
@echo "install - zainstaluj zależności WSZYSTKICH warstw do aktywnego venv"
@echo "sample - wygeneruj przykładowe pliki .xlsx"
@echo "dev-data - uruchom warstwę danych lokalnie (:8002)"
@echo "dev-logic - uruchom warstwę logiczną lokalnie (:8001)"
@echo "dev-presentation - uruchom warstwę prezentacji lokalnie (:8000)"
@echo "test - testy warstwy logicznej (silnik efemeryd)"
@echo "clean-cache - wyczyść regenerowalny cache warstwy danych"
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
@echo "migrate - ETL: Excel -> SQL"
@echo "up / down - uruchom / zatrzymaj cały stos (Docker; wykryto: $(COMPOSE))"
@echo "sql - Docker z warstwą SQL (DATA_PROVIDER=sql)"
@echo ""
@echo "Szybki start lokalny (bez Dockera), w 3 terminalach:"
@echo " make install"
@echo " make sample # opcjonalnie: dane przykładowe"
@echo " make dev-data # terminal 1"
@echo " make dev-logic # terminal 2"
@echo " make dev-presentation # terminal 3 -> http://localhost:8000"
up:
docker compose up --build
down:
docker compose down
# --- instalacja zależności do aktywnego środowiska ---
install:
$(PY) -m pip install \
-r services/data/requirements.txt \
-r services/logic/requirements.txt \
-r services/presentation/requirements.txt
@echo "OK. Zależności warstw zainstalowane. (Silnik AGPL 'engine-swisseph' instaluje się osobno.)"
sample:
cd services/data && python scripts/make_sample_data.py
cd services/data && $(PY) scripts/make_sample_data.py
reindex:
cd services/data && python -m app.ingest.build_index
cd services/data && $(PY) -m app.ingest.build_index
migrate:
cd services/data && python -m app.ingest.to_sql
cd services/data && $(PY) -m app.ingest.to_sql
sql:
DATA_PROVIDER=sql docker compose up --build
test:
cd services/logic && PYTHONPATH=. $(PY) -m pytest tests -q
clean-cache:
rm -rf services/data/.cache/* services/logic/.ephemeris/* 2>/dev/null || true
@echo "Cache wyczyszczony."
# --- lokalny dev (3 osobne terminale) ---
dev-data:
@@ -36,3 +61,20 @@ dev-logic:
dev-presentation:
cd services/presentation && LOGIC_URL=http://localhost:8001 uvicorn app.main:app --reload --port 8000
# --- Docker (opcjonalnie) ---
up:
@if [ -z "$(COMPOSE)" ]; then \
echo "Nie znaleziono Docker Compose (ani 'docker compose', ani 'docker-compose')."; \
echo "Użyj trybu lokalnego: make install, potem dev-data / dev-logic / dev-presentation."; \
exit 1; \
fi
$(COMPOSE) up --build
down:
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
$(COMPOSE) down
sql:
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
DATA_PROVIDER=sql $(COMPOSE) up --build
+11 -3
View File
@@ -28,13 +28,20 @@ make up # zbuduj i uruchom 3 warstwy
# otwórz http://localhost:8000
```
## Szybki start (lokalnie, 3 terminale)
## Szybki start (lokalnie, bez Dockera — zalecane)
```bash
python -m venv .env && source .env/bin/activate # venv (jednorazowo)
make install # zależności WSZYSTKICH warstw
make sample # opcjonalnie: dane przykładowe
```
Potem w 3 osobnych terminalach (w każdym `source .env/bin/activate`):
```bash
cd services/data && pip install -r requirements.txt && python scripts/make_sample_data.py
make dev-data # terminal 1 -> :8002
make dev-logic # terminal 2 -> :8001
make dev-presentation # terminal 3 -> :8000
make dev-presentation # terminal 3 -> :8000 -> http://localhost:8000
```
> `make install` instaluje zależności wszystkich trzech warstw do aktywnego venv.
> Testy silnika: `make test`. Wyczyszczenie cache: `make clean-cache`.
## Modułowość — dowód
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
@@ -66,3 +73,4 @@ export DATA_PROVIDER=sql # przełącz całą warstwę
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
# updater test Mon 20 Jul 2026 19:52:08 CEST
+12
View File
@@ -16,11 +16,23 @@ services:
build: ./services/logic
environment:
DATA_URL: http://data:8002
EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny
# adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison)
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on:
- data
ports:
- "8001:8001"
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up
# Nie wchodzi do domyślnego (zamkniętego) produktu — patrz services/engine-swisseph/LICENSE.
engine-swisseph:
build: ./services/engine-swisseph
profiles: ["comparison"]
ports:
- "8003:8003"
presentation:
build: ./services/presentation
environment:
Binary file not shown.
+7 -2
View File
@@ -31,5 +31,10 @@ class FrameCache:
return None
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
frame.to_parquet(self._path(fp, sheet), index=False)
# Kolumny z heterogenicznych plików Excela bywają mieszane (int + str +
# NaN w jednej kolumnie) — pyarrow tego nie zapisze. Warstwa danych i tak
# operuje na tekście (wyszukiwanie po .astype(str)), więc zapisujemy ramkę
# jako string (brak wartości -> pusty tekst). Gwarantuje to stabilny zapis
# Parquet niezależnie od zawartości pliku źródłowego.
safe = frame.astype("string").fillna("")
safe.to_parquet(self._path(fp, sheet), index=False)
+2
View File
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from app import security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.factory import build_provider
@@ -24,6 +25,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
security.install(app, "danych") # token międzywarstwowy (LOG-32)
@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=1000)
# Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
# realnie potrzebuje build_report na jeden obiekt.
limit: int = Field(50, ge=1, le=5000)
fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
)
@@ -82,7 +82,10 @@ class ExcelDataProvider(DataProvider):
def warmup(self) -> None:
for path in self._excel_files():
self._ensure_indexed(path)
try:
self._ensure_indexed(path)
except Exception as e: # jeden uszkodzony plik nie może zablokować startu
print(f"[data] pominięto plik przy indeksowaniu: {path}{e}")
def _excel_files(self) -> list[str]:
base = Path(self.s.excel_dir)
@@ -112,7 +115,9 @@ class ExcelDataProvider(DataProvider):
if query.exact:
mask = col.str.lower() == query.value.lower()
else:
mask = col.str.lower().str.contains(query.value.lower(), na=False)
# regex=False: wartości sygnifikatorów zawierają znaki [ + itd.,
# które są metaznakami regex — szukamy dosłownie.
mask = col.str.lower().str.contains(query.value.lower(), na=False, regex=False)
matched = frame[mask]
if query.fields:
keep = [c for c in query.fields if c in matched.columns]
+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)
+34
View File
@@ -0,0 +1,34 @@
# Build wieloetapowy — bo `pyswisseph` to rozszerzenie C bez gotowych wheeli.
#
# Na PyPI (2.10.3.2) wheels kończą się na cp311 i obejmują wyłącznie i686/x86_64.
# Dla Pythona 3.12 oraz dla arm64 pip ZAWSZE kompiluje ze źródeł, a `-slim` nie ma
# kompilatora — dlatego jednoetapowy build tu padał. Kompilujemy w etapie builder,
# a do obrazu finalnego wchodzi już tylko gotowy wheel (bez toolchaina).
FROM python:3.12-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY . .
# Sanity check na etapie budowania: brak działającego swissepha ma wywalić build,
# a nie dopiero pierwszy request.
RUN python -c "import swisseph as swe; swe.set_ephe_path(None); print('swisseph OK', swe.version)"
EXPOSE 8003
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
+19
View File
@@ -0,0 +1,19 @@
Ten komponent (services/engine-swisseph) jest licencjonowany na warunkach
GNU AFFERO GENERAL PUBLIC LICENSE wersja 3 (AGPL-3.0-or-later).
Powód: linkuje bibliotekę pyswisseph / Swiss Ephemeris (Astrodienst AG), która
jest udostępniana na zasadzie podwójnego licencjonowania: AGPL-3.0 ALBO płatna
licencja komercyjna. Wybierając wariant AGPL, ten komponent również jest AGPL.
WAŻNE — IZOLACJA: ten komponent jest celowo wydzielony jako osobny proces/usługa
i komunikuje się z resztą systemu wyłącznie przez HTTP. Pozostałe komponenty
projektu (warstwa prezentacji, warstwa logiczna z silnikiem własnym, warstwa
danych) NIE są dziełem pochodnym tego komponentu ani Swiss Ephemeris i pozostają
na licencji permisywnej. Ten komponent NIE wchodzi do dystrybucji zamkniętego
produktu — służy jako wyrocznia walidacyjna / tryb porównawczy (dev/CI).
Pełny tekst licencji AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.txt
Alternatywa: zamiast wariantu AGPL można nabyć komercyjną licencję Swiss
Ephemeris od Astrodienst AG — wówczas warunki tego komponentu należy dostosować
do tej licencji.
+38
View File
@@ -0,0 +1,38 @@
# engine-swisseph (silnik B — AGPL, izolowany)
Osobna, **opcjonalna** usługa będąca drugim silnikiem efemeryd (LOG27). Liczy
pozycje przez **pyswisseph / Swiss Ephemeris** i służy jako **wyrocznia
walidacyjna / tryb porównawczy** (LOG25/26) dla naszego silnika własnego.
## ⚠️ Licencja
Ten komponent jest **AGPL3.0** (bo linkuje Swiss Ephemeris) — patrz [LICENSE](LICENSE).
Jest **wydzielony jako osobny proces** i wołany przez HTTP, więc nie „zaraża"
permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.**
## API
- `POST /positions``{when_utc, lat, lon, objects?}` → pozycje (ten sam kształt co silnik własny)
- `GET /health`
Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji.
## Build obrazu
```bash
docker compose --profile comparison build engine-swisseph
```
Dockerfile jest **wieloetapowy** i to nie jest ozdobnik: `pyswisseph` to rozszerzenie
C, a na PyPI (2.10.3.2) gotowe wheels kończą się na **cp311** i obejmują wyłącznie
**i686/x86_64**. Dla Pythona 3.12 oraz dla arm64 pip musi kompilować ze źródeł, więc
sam `python:3.12-slim` (bez kompilatora) build wywracał. Kompilacja idzie w etapie
`builder` (`build-essential`), a do obrazu finalnego trafia już tylko gotowy wheel —
runtime zostaje czysty i mały. Pierwszy build trwa ~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 # wymaga kompilatora C (patrz wyżej)
uvicorn app.main:app --port 8003
```
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
aby włączyć silnik B (tryb dual-run i testy kontraktowe silnika B).
+74
View File
@@ -0,0 +1,74 @@
"""engine-swisseph — IZOLOWANA usługa silnika B (AGPL).
UWAGA LICENCYJNA: ta usługa linkuje pyswisseph / Swiss Ephemeris, więc jest
objęta **AGPL-3.0** i jest licencjonowana osobno (patrz ./LICENSE). Jest celowo
wydzielona jako osobny proces i wołana przez HTTP — dzięki temu permisywny
produkt (prezentacja + logika z silnikiem własnym + dane) NIE jest linkowany z
kodem AGPL i nie podlega jego obowiązkom (LOG-27).
Rola: wyrocznia walidacyjna / tryb porównawczy (LOG-25/26). Nie wchodzi do
dystrybucji zamkniętej — uruchamiana tylko w profilu porównawczym/dev/CI.
Udostępnia ten sam kontrakt co RemoteEngine po stronie warstwy logicznej.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel
import swisseph as swe
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
_PLANETS = {
"Sun": swe.SUN, "Moon": swe.MOON, "Mercury": swe.MERCURY, "Venus": swe.VENUS,
"Mars": swe.MARS, "Jupiter": swe.JUPITER, "Saturn": swe.SATURN,
"Uranus": swe.URANUS, "Neptune": swe.NEPTUNE, "Pluto": swe.PLUTO,
# punkty wirtualne — mean, jak w silniku własnym (parzystość LOG-28)
"North Node": swe.MEAN_NODE, "Lilith": swe.MEAN_APOG,
# "South Node" obsługiwany pochodnie w /positions: NN + 180°
}
DEFAULT_OBJECTS = [
"Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
"Uranus", "Neptune", "Pluto", "North Node", "South Node", "Lilith",
]
class PositionsRequest(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
objects: list[str] | None = None
@app.post("/positions")
def positions(req: PositionsRequest) -> dict:
d = req.when_utc
ut_hours = d.hour + d.minute / 60.0 + d.second / 3600.0
jd = swe.julday(d.year, d.month, d.day, ut_hours) # czas uniwersalny
rows = []
for name in (req.objects or DEFAULT_OBJECTS):
lookup = "North Node" if name == "South Node" else name
xx, _retflag = swe.calc_ut(jd, _PLANETS[lookup], _FLAGS)
lon, lat, _dist, lon_speed = xx[0], xx[1], xx[2], xx[3]
if name == "South Node":
lon, lat = lon + 180.0, -lat
rows.append({
"name": name,
"longitude": lon % 360.0,
"latitude": lat,
"speed": lon_speed,
"retrograde": lon_speed < 0,
})
return {"engine": "swisseph", "positions": rows}
@app.get("/health")
def health() -> dict:
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
@@ -0,0 +1,6 @@
# UWAGA: pyswisseph (Swiss Ephemeris) jest na licencji AGPL-3.0 — dlatego ta
# usługa jest wydzielona i licencjonowana osobno (patrz LICENSE). Nie instaluj
# tego w obrazie permisywnego produktu.
fastapi>=0.115
uvicorn[standard]>=0.34
pyswisseph>=2.10
+23
View File
@@ -6,8 +6,31 @@ i nie w bazie.
## API
- `POST /api/query``QueryRequest``QueryResponse`
- `POST /chart/positions``{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Zwraca też sektę i 7 Lots hermetycznych z domami (LOG-08). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
- `POST /chart/report``{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
- `POST /chart/profections``{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10)
- `POST /chart/return``{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
- `POST /chart/firdaria``{when_utc, lat, lon}` → Firdaria: sekta (dzień/noc), okresy główne i podokresy time-lordów (LOG-11)
- `POST /chart/timeline``{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc + Firdaria, posortowane (technique | significator | start | exact | end); z interpret=true dopina interpretacje z bazy do dat (LOG-14, 1B->2B)
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
- `GET /health` (sprawdza też warstwę bazodanową)
## Silnik efemeryd (LOG-24, „wymienny silnik liczący")
W `app/engine/` żyje pluggable silnik za interfejsem `EphemerisEngine`:
- **`SkyfieldEngine`** — własny, permisywny (Skyfield MIT + dane JPL public domain). Domyślny.
- **`RemoteEngine`** — klient OSOBNEJ, izolowanej usługi `engine-swisseph` (AGPL), używany tylko w trybie porównawczym.
Wybór: `EPHEMERIS_ENGINE=own|swisseph`. Silnik B włącza się przez `ENGINE_SWISSEPH_URL`.
Walidacja (LOG-25/28): `app/engine/compare.py` zestawia oba silniki z progiem tolerancji;
ten sam kontrakt parzystości obowiązuje każdy silnik. Nasz `SkyfieldEngine` zgadza się
ze Swiss Ephemeris **co do ~1″** na horoskopie referencyjnym (patrz `tests/`).
```bash
pip install -r requirements-dev.txt
PYTHONPATH=. pytest tests -q # silnik B pomijany, jeśli ENGINE_SWISSEPH_URL nieustawiony
```
## Zależności w dół
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
Nie wie, czy pod spodem jest Excel czy SQL.
+60
View File
@@ -0,0 +1,60 @@
"""Rozwijanie skrótów sygnifikatorów do postaci czytelnej (na bazie SIGNIFICATORS KEY).
W bazie zapis jest skrótowy z prefiksem `[` (np. `[Su in [Tau`, `[Sa [conj [Su in 6th H.`).
Ten moduł zamienia go na tekst czytelny: „Sun in Taurus", „Saturn conjunction Sun
in 6th house". Słownik pochodzi z pliku SIGNIFICATORS KEY (mały, stabilny — wpięty
jako built-in; można rozszerzać).
"""
from __future__ import annotations
import re
# skrót (bez nawiasu) -> pełna nazwa
ABBREVIATIONS: dict[str, str] = {
# znaki zodiaku
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer",
"Leo": "Leo", "Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio",
"Sag": "Sagittarius", "Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
# planety klasyczne + światła
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus",
"Ma": "Mars", "Ju": "Jupiter", "Sa": "Saturn",
# planety nowożytne
"Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
# węzły i punkty
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
# osie
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
# Lots (punkty arabskie)
"PF": "Part of Fortune", "Fortune": "Part of Fortune", "Spirit": "Lot of Spirit",
# aspekty
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
"sesquisq": "sesquisquare", "quincunx": "quincunx", "asp": "aspect",
# domy jako tokeny [h1..[h12
**{f"h{i}": f"{i}th house" for i in range(1, 13)},
# ruch
"Rx": "retrograde", "R": "retrograde",
}
# poprawki nieregularnych liczebników domów
ABBREVIATIONS.update({"h1": "1st house", "h2": "2nd house", "h3": "3rd house"})
# rozwinięcia słów spoza składni `[`
_WORDS = {
"affl.": "afflicted",
"P. Dec.": "parallel of declination",
"espec.": "especially",
}
_TOKEN = re.compile(r"\[([A-Za-z]+)")
_HOUSE = re.compile(r"(\d+(?:st|nd|rd|th))\s*H\.", re.IGNORECASE)
def expand(text: str) -> str:
"""Zamienia skróty na pełne nazwy; nieznane tokeny zostawia bez nawiasu."""
if not text:
return text
out = _TOKEN.sub(lambda m: ABBREVIATIONS.get(m.group(1), m.group(1)), text)
out = _HOUSE.sub(lambda m: f"{m.group(1)} house", out)
for k, v in _WORDS.items():
out = out.replace(k, v)
return out
+19 -5
View File
@@ -5,6 +5,7 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
"""
from __future__ import annotations
import os
from typing import Any
import httpx
@@ -12,19 +13,32 @@ import httpx
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 {}
class DataClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.data_url).rstrip("/")
def search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit}
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/search", json=payload)
def search(
self,
key: str,
value: str,
exact: bool,
limit: int,
fields: list[str] | None = None,
) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/search", json=payload, headers=_auth_headers())
r.raise_for_status()
return r.json()
def health(self) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health")
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
r.raise_for_status()
return r.json()
+101
View File
@@ -0,0 +1,101 @@
"""Aspekty — kąty między obiektami (LOG-06, wersja: aspekty główne).
Czysta matematyka na policzonych długościach ekliptycznych. Dla każdej pary
obiektów sprawdzamy, czy ich separacja kątowa mieści się w orbie któregoś z
aspektów głównych. Applying/separating (aplikacja/separacja) — na później.
Tokeny bazy (z SIGNIFICATORS KEY): [conj, [sex, [sq, [tri, [opp.
"""
from __future__ import annotations
MAJOR = {
"conjunction": 0.0,
"sextile": 60.0,
"square": 90.0,
"trine": 120.0,
"opposition": 180.0,
}
DB_TOKEN = {
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
"trine": "[tri", "opposition": "[opp",
}
PL_NAME = {
"conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura",
"trine": "trygon", "opposition": "opozycja",
}
LUMINARIES = {"Sun", "Moon"}
DEFAULT_ORB = 8.0
LUMINARY_BONUS = 2.0
# Pary sztywno powiązane definicyjnie — kąt między nimi wynika z samej definicji
# punktu, nie z układu nieba (SN = NN + 180°). Aspekt taki zawsze wychodzi
# dokładny (orb 0,00°) i nie niesie żadnej informacji astrologicznej, więc
# wycinamy go z wyników: zaśmieca listę w UI i zjada budżet promptu do LLM.
RIGID_PAIRS = frozenset({
frozenset({"North Node", "South Node"}),
})
def _is_rigid(name_a: str, name_b: str) -> bool:
return frozenset({name_a, name_b}) in RIGID_PAIRS
def separation(a: float, b: float) -> float:
"""Najmniejsza separacja kątowa [0,180]."""
d = abs(a - b) % 360.0
return min(d, 360.0 - d)
def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None:
"""Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)?
Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu
(pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying.
dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności.
Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć).
"""
if sa is None or sb is None:
return None
dev_now = abs(separation(la, lb) - angle)
dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle)
return dev_next < dev_now
def find_aspects(
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]:
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
"""
out: list[dict] = []
n = len(positions)
for i in range(n):
for j in range(i + 1, n):
a, b = positions[i], positions[j]
if _is_rigid(a["name"], b["name"]):
continue
la, lb = a.get("decimal"), b.get("decimal")
if la is None or lb is None:
continue
sep = separation(float(la), float(lb))
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
for asp, angle in MAJOR.items():
dev = abs(sep - angle)
if dev <= allowed:
applying = _is_applying(
float(la), float(lb), a.get("speed"), b.get("speed"), angle
)
row = {
"obj1": a["name"], "obj2": b["name"],
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
}
if applying is not None:
row["applying"] = applying
row["as"] = "A" if applying else "S"
out.append(row)
break # jedna para = jeden aspekt
return out
+24
View File
@@ -0,0 +1,24 @@
"""Interfejs silnika efemeryd (LOG-24).
To jest „wymienny silnik liczący". Dziś realizują go: SkyfieldEngine (własny,
permisywny, in-process) i RemoteEngine (klient izolowanej usługi swisseph, AGPL).
Warstwa logiczna woła tylko ten interfejs — nie wie, który silnik liczy.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from app.engine.models import ChartMoment, ObjectPosition
class EphemerisEngine(ABC):
name: str = "base"
@abstractmethod
def positions(
self, moment: ChartMoment, objects: list[str] | None = None
) -> list[ObjectPosition]:
"""Pozycje obiektów dla danego momentu (LOG-01). None = zestaw domyślny."""
def health(self) -> dict:
return {"engine": self.name, "status": "ok"}
+95
View File
@@ -0,0 +1,95 @@
"""Złożenie pełnego horoskopu: pozycje + osie + domy (LOG-01 + LOG-05).
Silnik-agnostyczne: potrzebuje tylko `positions()` oraz (dla osi/domów)
`sidereal()`. Jeśli silnik nie umie policzyć czasu gwiazdowego, zwraca same
pozycje.
"""
from __future__ import annotations
from app.engine import houses as H
from app.engine import zodiac as Z
from app.engine.base import EphemerisEngine
from app.engine.formats import SIGNS, absolute, decimal, in_sign, norm360, sign_index
from app.engine.models import ChartMoment
def _fmt(name: str, lon: float, off: float = 0.0) -> dict:
lon = norm360(lon - off)
return {
"name": name,
"sign": SIGNS[sign_index(lon)],
"in_sign": in_sign(lon),
"decimal": round(lon, 6),
}
def _shift_pos(pdict: dict, off: float) -> None:
"""Przelicza etykiety pozycji na wybrany zodiak (in-place). off=0 → bez zmian."""
if not off:
return
lon = norm360(pdict["decimal"] - off)
pdict["sign"] = SIGNS[sign_index(lon)]
pdict["in_sign"] = in_sign(lon)
pdict["absolute"] = absolute(lon)
pdict["decimal"] = decimal(lon)
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
from app.engine.aspects import find_aspects
positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
result["zodiac"] = zodiac
if zodiac in Z.SIDEREAL:
result["ayanamsha"] = round(off, 6)
for pdict in result["positions"]:
_shift_pos(pdict, off)
if not hasattr(engine, "sidereal"):
return result
ramc, eps = engine.sidereal(moment)
asc = H.compute_asc(ramc, eps, moment.lat)
mc = H.compute_mc(ramc, eps)
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
result["house_system"] = system
result["angles"] = {
"Asc": _fmt("Asc", asc, off),
"MC": _fmt("MC", mc, off),
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
"IC": _fmt("IC", norm360(mc + 180.0), off),
}
result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off))}
for i, c in enumerate(cusp_list)
]
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
from app.engine.firdaria import is_day_birth
from app.engine.lots import compute_lots
pts = {p.name: p.longitude for p in positions}
pts["Asc"] = asc
day = is_day_birth(pts["Sun"], asc, mc) if "Sun" in pts else True
result["sect"] = "day" if day else "night"
result["lots"] = [
{**lot,
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
"in_sign": in_sign(norm360(lot["longitude"] - off)),
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
for lot in compute_lots(pts, day, lots_method)
]
return result
+104
View File
@@ -0,0 +1,104 @@
"""Harness walidacyjno-porównawczy (LOG-25) i kontrakt parzystości (LOG-28).
`compare_positions` zestawia wyniki dwóch silników z progami tolerancji per
wielkość i flaguje rozbieżności — używane w testach regresyjnych (CI) i w trybie
dual-run na żądanie (LOG-26).
`check_engine_contract` to wspólny kontrakt, który MUSI spełnić każdy silnik —
ten sam test uruchamiamy dla EngineA i EngineB (LOG-28).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from app.engine.base import EphemerisEngine
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
def angular_delta(a: float, b: float) -> float:
"""Najmniejsza różnica kątów w stopniach (z obsługą zawinięcia 0/360)."""
return ((a - b + 180.0) % 360.0) - 180.0
@dataclass
class ObjectDiff:
name: str
lon_a: float
lon_b: float
delta_arcsec: float # różnica długości w sekundach łuku
lat_delta: float
speed_sign_mismatch: bool
over_tolerance: bool
@dataclass
class CompareReport:
lon_tol_arcsec: float
diffs: list[ObjectDiff] = field(default_factory=list)
@property
def ok(self) -> bool:
return not any(d.over_tolerance or d.speed_sign_mismatch for d in self.diffs)
@property
def max_arcsec(self) -> float:
return max((abs(d.delta_arcsec) for d in self.diffs), default=0.0)
def summary(self) -> dict:
return {
"ok": self.ok,
"objects": len(self.diffs),
"max_arcsec": round(self.max_arcsec, 2),
"tolerance_arcsec": self.lon_tol_arcsec,
"flagged": [d.name for d in self.diffs if d.over_tolerance or d.speed_sign_mismatch],
}
def compare_positions(
a: list[ObjectPosition],
b: list[ObjectPosition],
lon_tol_arcsec: float = 120.0,
) -> CompareReport:
by_b = {p.name: p for p in b}
report = CompareReport(lon_tol_arcsec=lon_tol_arcsec)
for pa in a:
pb = by_b.get(pa.name)
if pb is None:
continue
d_arcsec = angular_delta(pa.longitude, pb.longitude) * 3600.0
report.diffs.append(
ObjectDiff(
name=pa.name,
lon_a=pa.longitude,
lon_b=pb.longitude,
delta_arcsec=d_arcsec,
lat_delta=pa.latitude - pb.latitude,
speed_sign_mismatch=(pa.retrograde != pb.retrograde),
over_tolerance=abs(d_arcsec) > lon_tol_arcsec,
)
)
return report
def compare_engines(
engine_a: EphemerisEngine,
engine_b: EphemerisEngine,
moment: ChartMoment,
lon_tol_arcsec: float = 120.0,
) -> CompareReport:
return compare_positions(
engine_a.positions(moment), engine_b.positions(moment), lon_tol_arcsec
)
def check_engine_contract(engine: EphemerisEngine, moment: ChartMoment) -> None:
"""Kontrakt parzystości (LOG-28). Rzuca AssertionError przy naruszeniu."""
positions = engine.positions(moment)
names = {p.name for p in positions}
assert set(DEFAULT_OBJECTS) <= names, f"brakuje obiektów: {set(DEFAULT_OBJECTS) - names}"
for p in positions:
assert 0.0 <= p.longitude < 360.0, f"{p.name}: długość poza zakresem ({p.longitude})"
assert -90.0 <= p.latitude <= 90.0, f"{p.name}: szerokość poza zakresem ({p.latitude})"
assert p.retrograde in (True, False)
d = p.as_dict()
assert d["sign"] and d["in_sign"], f"{p.name}: brak formatów"
+30
View File
@@ -0,0 +1,30 @@
"""Fabryka silników (LOG-24) — jedyne miejsce znające konkretne implementacje.
EPHEMERIS_ENGINE = own (domyślnie, permisywny Skyfield) | swisseph (zdalny AGPL).
`available_engines()` zwraca to, co da się dziś uruchomić — używane przez
harness porównawczy i testy parzystości.
"""
from __future__ import annotations
import os
from app.engine.base import EphemerisEngine
def build_engine(name: str | None = None) -> EphemerisEngine:
name = (name or os.getenv("EPHEMERIS_ENGINE", "own")).lower()
if name in ("swisseph", "remote", "b"):
from app.engine.remote_engine import RemoteEngine
return RemoteEngine()
from app.engine.skyfield_engine import SkyfieldEngine
return SkyfieldEngine()
def available_engines() -> dict[str, EphemerisEngine]:
"""Silniki gotowe do użycia teraz (own zawsze; swisseph jeśli skonfigurowany)."""
engines: dict[str, EphemerisEngine] = {"own": build_engine("own")}
if os.getenv("ENGINE_SWISSEPH_URL"):
engines["swisseph"] = build_engine("swisseph")
return engines
+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}
+52
View File
@@ -0,0 +1,52 @@
"""Formatowanie długości ekliptycznej (LOG-01: kilka zapisów).
Astrolog myśli w stopniach/minutach/sekundach w znaku, Excel woli dziesiętne,
a część technik używa pozycji absolutnej 0360°. Tu są czyste, bezstanowe
funkcje konwersji — bez zależności od jakiegokolwiek silnika.
"""
from __future__ import annotations
SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]
SIGN_ABBR = ["Ari", "Tau", "Gem", "Can", "Leo", "Vir",
"Lib", "Sco", "Sag", "Cap", "Aqu", "Pis"]
def norm360(lon: float) -> float:
return lon % 360.0
def sign_index(lon: float) -> int:
"""0 = Aries … 11 = Pisces."""
return int(norm360(lon) // 30)
def _dms(deg: float) -> tuple[int, int, int]:
"""Rozkład stopni (>=0) na (°, ', ") z poprawnym przeniesieniem zaokrąglenia."""
total = round(deg * 3600)
d, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
return d, m, s
def in_sign(lon: float) -> str:
"""Np. 'Tau 28°12'57\"' — pozycja w znaku."""
lon = norm360(lon)
idx = sign_index(lon)
d, m, s = _dms(lon - idx * 30)
if d >= 30: # zaokrąglenie przekroczyło granicę znaku
idx = (idx + 1) % 12
d -= 30
return f"{SIGN_ABBR[idx]} {d}°{m:02d}'{s:02d}\""
def absolute(lon: float) -> str:
"""Np. '58°12'57\"' — pozycja absolutna 0360°."""
d, m, s = _dms(norm360(lon))
return f"{d}°{m:02d}'{s:02d}\""
def decimal(lon: float, places: int = 6) -> float:
return round(norm360(lon), places)
+77
View File
@@ -0,0 +1,77 @@
"""Osie i domy — czysta matematyka sferyczna (LOG-05).
Bezstanowe funkcje: z lokalnego czasu gwiazdowego (RAMC), nachylenia ekliptyki
(ε) i szerokości geograficznej (φ) wyliczają Ascendent i MC, a stąd cusps domów
dla prostych systemów (Whole Sign, Equal, Porphyry). Niezależne od silnika —
silnik dostarcza tylko RAMC i ε.
"""
from __future__ import annotations
import math
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
WHOLE_SIGN = "whole_sign"
EQUAL = "equal"
PORPHYRY = "porphyry"
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
def mean_obliquity(tt_jd: float) -> float:
"""Średnie nachylenie ekliptyki [°] dla daty (Julian TT). Wystarcza do domów."""
t = (tt_jd - 2451545.0) / 36525.0
arcsec = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t ** 3
return arcsec / 3600.0
def compute_mc(ramc_deg: float, eps_deg: float) -> float:
r, e = math.radians(ramc_deg), math.radians(eps_deg)
mc = math.atan2(math.sin(r), math.cos(r) * math.cos(e))
return norm360(math.degrees(mc))
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
asc = math.atan2(
math.cos(r),
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
)
return norm360(math.degrees(asc))
def _trisect(a: float, b: float) -> tuple[float, float]:
"""Dwa punkty dzielące łuk a→b (w kierunku zodiaku) na trzy równe części."""
span = (b - a) % 360.0
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
def cusps(asc: float, mc: float, system: str) -> list[float]:
"""Zwraca 12 cusps (długości) domów 1..12."""
if system == WHOLE_SIGN:
start = sign_index(asc) * 30.0
return [norm360(start + 30.0 * i) for i in range(12)]
if system == EQUAL:
return [norm360(asc + 30.0 * i) for i in range(12)]
if system == PORPHYRY:
dsc, ic = norm360(asc + 180.0), norm360(mc + 180.0)
c = [0.0] * 12
c[0], c[3], c[6], c[9] = asc, ic, dsc, mc
c[1], c[2] = _trisect(asc, ic) # domy 2,3
c[4], c[5] = _trisect(ic, dsc) # domy 5,6
c[7], c[8] = _trisect(dsc, mc) # domy 8,9
c[10], c[11] = _trisect(mc, asc) # domy 11,12
return c
raise ValueError(f"nieznany system domów: {system}")
def assign_house(lon: float, cusp_list: list[float]) -> int:
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
lon = norm360(lon)
for i in range(12):
start = cusp_list[i]
end = cusp_list[(i + 1) % 12]
span = (end - start) % 360.0
offset = (lon - start) % 360.0
if offset < span:
return i + 1
return 12
+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
+66
View File
@@ -0,0 +1,66 @@
"""Modele domenowe silnika efemeryd — wspólny kontrakt dla KAŻDEGO silnika.
To jest część LOG-28 (parzystość): każdy silnik (własny Skyfield czy zdalny
swisseph) przyjmuje `ChartMoment` i zwraca listę `ObjectPosition` w identycznym
kształcie i jednostkach. Dzięki temu wyniki są bezpośrednio porównywalne, a
prezentacja/dane nie wiedzą, który silnik liczył.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from app.engine import formats
# kanoniczny zestaw i kolejność obiektów (LOG-02: światła + 7 klasycznych +
# 3 nowożytne + punkty wirtualne: węzły mean i mean Lilith)
DEFAULT_OBJECTS = [
"Sun", "Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
"North Node", "South Node", "Lilith",
]
@dataclass(frozen=True)
class ChartMoment:
"""Wejście silnika: moment w UTC + lokalizacja geograficzna.
Pozycje obiektów zależą tylko od czasu (geocentrycznie); szerokość/długość
geograficzna będą potrzebne dopiero przy osiach i domach (LOG-05).
"""
when_utc: datetime # musi być świadome strefy (UTC)
lat: float = 0.0 # szerokość geograficzna, + na północ
lon: float = 0.0 # długość geograficzna, + na wschód
@dataclass(frozen=True)
class ObjectPosition:
"""Wynik dla jednego obiektu — surowe wartości w jednostkach SI astrologii."""
name: str
longitude: float # długość ekliptyczna 0360° (tropikalna, of-date)
latitude: float # szerokość ekliptyczna (°)
speed: float # prędkość w długości (°/dobę)
retrograde: bool
@property
def sign(self) -> str:
return formats.SIGNS[formats.sign_index(self.longitude)]
@property
def direction(self) -> str:
return "Rx" if self.retrograde else "D"
def as_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"sign": self.sign,
"in_sign": formats.in_sign(self.longitude),
"absolute": formats.absolute(self.longitude),
"decimal": formats.decimal(self.longitude),
"latitude": round(self.latitude, 6),
"speed": round(self.speed, 6),
"direction": self.direction,
}
+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
@@ -0,0 +1,62 @@
"""RemoteEngine — klient izolowanej usługi silnika (LOG-24 backend nr 2, LOG-27).
Realizuje ten sam interfejs co SkyfieldEngine, ale liczenie deleguje przez HTTP
do OSOBNEJ usługi `engine-swisseph` (AGPL). Dzięki granicy sieciowej kod AGPL
nigdy nie jest linkowany do permisywnego produktu — patrz services/engine-swisseph.
Używany tylko, gdy skonfigurowano ENGINE_SWISSEPH_URL (tryb porównawczy/dev/CI).
"""
from __future__ import annotations
import os
import httpx
from app.engine.base import EphemerisEngine
from app.engine.formats import norm360
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
class RemoteEngine(EphemerisEngine):
name = "swisseph"
def __init__(self, base_url: str | None = None, timeout: float = 15.0) -> None:
self.base_url = (base_url or os.getenv("ENGINE_SWISSEPH_URL", "")).rstrip("/")
self.timeout = timeout
def positions(
self, moment: ChartMoment, objects: list[str] | None = None
) -> list[ObjectPosition]:
if not self.base_url:
raise RuntimeError("ENGINE_SWISSEPH_URL nie ustawiony — silnik B niedostępny")
payload = {
"when_utc": moment.when_utc.isoformat(),
"lat": moment.lat,
"lon": moment.lon,
"objects": objects or DEFAULT_OBJECTS,
}
with httpx.Client(timeout=self.timeout) as client:
r = client.post(f"{self.base_url}/positions", json=payload)
r.raise_for_status()
rows = r.json()["positions"]
return [
ObjectPosition(
name=row["name"],
longitude=norm360(row["longitude"]),
latitude=row["latitude"],
speed=row["speed"],
retrograde=row["retrograde"],
)
for row in rows
]
def health(self) -> dict:
if not self.base_url:
return {"engine": self.name, "status": "disabled"}
try:
with httpx.Client(timeout=self.timeout) as client:
r = client.get(f"{self.base_url}/health")
r.raise_for_status()
return {"engine": self.name, "status": "ok", "remote": r.json()}
except httpx.HTTPError as e:
return {"engine": self.name, "status": "down", "error": str(e)}
+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))
@@ -0,0 +1,128 @@
"""SkyfieldEngine — własny, permisywny silnik (LOG-01).
Ścieżka A: Skyfield (MIT) + efemerydy JPL (public domain). Liczy geocentryczne
pozycje pozorne (apparent) i rzutuje je na ekliptykę daty → długość tropikalna,
szerokość, prędkość i kierunek. Brak zależności AGPL.
Prędkość liczymy numerycznie (różnica długości po małym kroku czasu) — wystarcza
do kierunku (D/Rx) i do wykrywania stacji w LOG-03.
"""
from __future__ import annotations
import os
from datetime import timedelta
from functools import lru_cache
from app.engine.base import EphemerisEngine
from app.engine.formats import norm360
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
# nazwa obiektu -> cel w jądrze efemeryd (de421 ma centra Merkurego/Wenus,
# dla pozostałych planet używamy barycentrów — różnica nieistotna astrologicznie)
_TARGETS = {
"Sun": "sun",
"Moon": "moon",
"Mercury": "mercury",
"Venus": "venus",
"Mars": "mars barycenter",
"Jupiter": "jupiter barycenter",
"Saturn": "saturn barycenter",
"Uranus": "uranus barycenter",
"Neptune": "neptune barycenter",
"Pluto": "pluto barycenter",
}
@lru_cache(maxsize=4)
def _load(kernel: str, data_dir: str):
"""Wczytuje skalę czasu i jądro efemeryd raz (kosztowne) i cache'uje."""
from skyfield.api import Loader
load = Loader(data_dir)
ts = load.timescale()
eph = load(kernel)
return ts, eph, eph["earth"]
class SkyfieldEngine(EphemerisEngine):
name = "skyfield"
def __init__(self, kernel: str | None = None, data_dir: str | None = None) -> None:
self.kernel = kernel or os.getenv("EPHEMERIS_KERNEL", "de421.bsp")
self.data_dir = data_dir or os.getenv(
"EPHEMERIS_DIR", os.path.join(os.path.dirname(__file__), "..", "..", ".ephemeris")
)
os.makedirs(self.data_dir, exist_ok=True)
self.ts, self.eph, self.earth = _load(self.kernel, os.path.abspath(self.data_dir))
def _ecliptic_lon_lat(self, target, t):
astrometric = self.earth.at(t).observe(target).apparent()
lat, lon, _dist = astrometric.ecliptic_latlon(epoch="date")
return lon.degrees, lat.degrees
def _virtual_point(self, name: str, tt_jd: float) -> ObjectPosition:
"""Punkty analityczne (LOG-02): mean Node (NN/SN) i mean Lilith.
Liczone wzorami Meeusa, nie z jądra JPL. SN = NN + 180° (ta sama prędkość).
Punkty leżą na ekliptyce (latitude = 0).
"""
from app.engine.points import mean_lilith, mean_lunar_node, point_speed
if name in ("North Node", "South Node"):
lon = mean_lunar_node(tt_jd)
if name == "South Node":
lon = norm360(lon + 180.0)
speed = point_speed(mean_lunar_node, tt_jd)
else: # Lilith
lon = mean_lilith(tt_jd)
speed = point_speed(mean_lilith, tt_jd)
return ObjectPosition(
name=name, longitude=float(lon), latitude=0.0,
speed=float(speed), retrograde=bool(speed < 0),
)
def positions(
self, moment: ChartMoment, objects: list[str] | None = None
) -> list[ObjectPosition]:
names = objects or DEFAULT_OBJECTS
t = self.ts.from_datetime(moment.when_utc)
dt = timedelta(hours=1)
t2 = self.ts.from_datetime(moment.when_utc + dt)
out: list[ObjectPosition] = []
for name in names:
if name not in _TARGETS: # punkt wirtualny (NN/SN/Lilith)
out.append(self._virtual_point(name, t.tt))
continue
target = self.eph[_TARGETS[name]]
lon, lat = self._ecliptic_lon_lat(target, t)
lon2, _ = self._ecliptic_lon_lat(target, t2)
# prędkość °/dobę z poprawką na przejście przez 0°/360°
step = ((lon2 - lon + 180.0) % 360.0) - 180.0
speed = step * 24.0
# rzutowanie na czysty float — Skyfield zwraca numpy.float64
out.append(
ObjectPosition(
name=name,
longitude=float(norm360(lon)),
latitude=float(lat),
speed=float(speed),
retrograde=bool(speed < 0),
)
)
return out
def sidereal(self, moment: ChartMoment) -> tuple[float, float]:
"""(RAMC, ε) w stopniach — lokalny apparent sidereal time i nachylenie ekliptyki.
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
"""
from app.engine.houses import mean_obliquity
t = self.ts.from_datetime(moment.when_utc)
ramc = norm360(t.gast * 15.0 + moment.lon)
eps = mean_obliquity(t.tt)
return ramc, eps
def health(self) -> dict:
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
+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)
+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."""
+70
View File
@@ -0,0 +1,70 @@
"""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.
Zmienne środowiskowe:
LLM_PROVIDER local (domyślnie) | openai | anthropic
LLM_MODEL nazwa modelu (domyślna zależy od dostawcy)
LLM_BASE_URL adres API (domyślnie: lokalny serwer zgodny z OpenAI)
LLM_API_KEY klucz — WYŁĄCZNIE z sekretu; niepotrzebny dla modelu lokalnego
LLM_TIMEOUT sekundy (domyślnie 120)
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
"""
from __future__ import annotations
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",
ANTHROPIC: "claude-sonnet-5",
}
_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 build_provider(name: str | None = None) -> LLMProvider:
name = (name or default_provider_name()).lower()
if name not in PROVIDERS:
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
model = os.getenv("LLM_MODEL") or _DEFAULT_MODEL[name]
base_url = os.getenv("LLM_BASE_URL") or _DEFAULT_URL[name]
api_key = os.getenv("LLM_API_KEY", "")
if name == ANTHROPIC:
return AnthropicProvider(base_url, model, api_key, timeout())
if name == OPENAI:
if not api_key:
raise LLMError("Brak LLM_API_KEY — dostawca openai wymaga klucza.")
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
leaves_lan=True)
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
return ChatCompletionsProvider(LOCAL, base_url, model, api_key, timeout(),
leaves_lan=False)
+143
View File
@@ -0,0 +1,143 @@
"""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.
"""
from __future__ import annotations
import time
import httpx
from app.llm.base import Completion, LLMError, LLMProvider
RETRY_STATUSES = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 3
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. Dłuższe horoskopy "
f"wymagają większego LLM_TIMEOUT albo mniejszego budżetu 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}")
class ChatCompletionsProvider(LLMProvider):
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
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 generate(self, prompt: str, max_tokens: int) -> Completion:
data = _post_with_retry(
f"{self.base_url}/chat/completions", self._headers(),
{
"model": self.model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
self.timeout,
)
try:
text = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as e:
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
return Completion(
text=text, model=data.get("model", self.model), provider=self.name,
leaves_lan=self.leaves_lan, usage=data.get("usage") or {},
)
def health(self) -> dict:
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
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(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 generate(self, prompt: str, max_tokens: int) -> Completion:
if not self.api_key:
raise LLMError("Brak LLM_API_KEY — dostawca anthropic wymaga klucza.")
data = _post_with_retry(
f"{self.base_url}/v1/messages",
{
"Content-Type": "application/json",
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
},
{
"model": self.model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
self.timeout,
)
try:
text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text")
except (KeyError, TypeError) as e:
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
return Completion(
text=text, model=data.get("model", self.model), provider=self.name,
leaves_lan=True, usage=data.get("usage") or {},
)
def health(self) -> dict:
return {
"provider": self.name, "model": self.model, "leaves_lan": True,
"status": "ok (klucz ustawiony)" if self.api_key else "brak LLM_API_KEY",
}
+342
View File
@@ -6,15 +6,42 @@ Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
"""
from __future__ import annotations
from datetime import datetime
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from app import 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)
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
_engine = None
def get_engine():
global _engine
if _engine is None:
from app.engine.factory import build_engine
_engine = build_engine()
return _engine
class PositionsRequest(BaseModel):
when_utc: datetime # moment w UTC (świadomy strefy)
lat: float = 0.0
lon: float = 0.0
objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@app.post("/api/query", response_model=QueryResponse)
@@ -25,6 +52,321 @@ def query(req: QueryRequest) -> QueryResponse:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
@app.post("/chart/positions")
def chart_positions(req: PositionsRequest) -> dict:
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
opcjonalnie stacje planet (LOG-03, stations=true)."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
try:
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
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")
def chart_compare(req: PositionsRequest) -> dict:
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
Wymaga skonfigurowanego ENGINE_SWISSEPH_URL (silnik B). W przeciwnym razie
zwraca informację, że porównanie jest niedostępne.
"""
from app.engine.compare import compare_engines
from app.engine.factory import build_engine
from app.engine.models import ChartMoment
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
try:
report = compare_engines(build_engine("own"), build_engine("swisseph"), moment)
except (RuntimeError, httpx.HTTPError) as e:
raise HTTPException(status_code=503, detail=f"Silnik B niedostępny: {e}")
return report.summary()
class ReportRequest(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
limit: int = 5000
group: bool = False # grupowanie identycznych opisów
@app.post("/chart/report")
def chart_report(req: ReportRequest) -> dict:
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.significators import build_report
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
chart = build_chart(engine, moment) # pozycje z domami + aspekty
try:
report = build_report(
chart["positions"], DataClient(),
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
)
except httpx.HTTPError as e:
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
return {"engine": engine.name, **report}
class PromptRequest(BaseModel):
"""Wejście generatora promptu (LOG-29/30)."""
profile: str = "natal" # natal | period
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
budget: str = "medium" # concise | medium | extensive
limit: int = 5000
# 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 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
# 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)
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)
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 + wybór dostawcy modelu (LOG-31)."""
provider: str | None = None # local (dom.) | openai | anthropic
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, max_tokens
out = chart_prompt(req) # ten sam prompt co w podglądzie
try:
provider = build_provider(req.provider)
result = provider.generate(out["prompt"], req.max_tokens or max_tokens())
except LLMError as e:
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
out["llm_error"] = str(e)
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/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"}
+339
View File
@@ -0,0 +1,339 @@
"""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,
}
DEFAULT_BUDGET = "medium"
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) -> int:
if budget not in BUDGETS:
raise ValueError(
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
)
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) -> dict:
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
limit = _budget_chars(budget)
chart_sec = _chart_section(chart, moment_label)
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
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) -> dict:
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
limit = _budget_chars(budget)
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")
+37
View File
@@ -0,0 +1,37 @@
"""Punktacja siły trafień — do rankingowania faset (zalążek LOG-21).
v1 liczy siłę z sygnałów OBLICZALNYCH:
- typ fasety (aspekt zwykle mocniejszy od znaku/domu),
- rodzaj aspektu (koniunkcja/opozycja mocniejsze od sekstyla),
- ciasnota orbu (im bliżej dokładności, tym mocniej).
Wszystko konfigurowalne. HOOK NA PRZYSZŁOŚĆ: gdy w SIGNIFICATORS KEY zostaną
wypełnione kolumny `countas*`/`level*`, można je tu domieszać per sygnifikator.
"""
from __future__ import annotations
# bazowe wagi faset (łatwe do strojenia)
FACET_BASE = {"sign": 5.0, "house": 5.0, "aspect": 6.0}
# względna siła aspektów głównych
ASPECT_WEIGHT = {
"conjunction": 1.0, "opposition": 0.95, "square": 0.85,
"trine": 0.85, "sextile": 0.65,
}
# aspekt aplikacyjny (A) jest silniejszy niż separacyjny (S) — notes3
APPLYING_BONUS = 1.15
def score_facet(facet: dict) -> float:
"""Siła fasety w skali ~012. Deterministyczna, konfigurowalna."""
base = FACET_BASE.get(facet.get("type"), 1.0)
if facet.get("type") == "aspect":
weight = ASPECT_WEIGHT.get(facet.get("aspect"), 0.7)
orb = facet.get("orb")
allowed = facet.get("allowed") or 10.0
tight = max(0.0, 1.0 - orb / allowed) if orb is not None and allowed else 0.0
score = base * weight * (1.0 + tight)
if facet.get("applying"):
score *= APPLYING_BONUS
return round(score, 2)
return round(base, 2)
+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)
+257
View File
@@ -0,0 +1,257 @@
"""Most: policzony horoskop → tokeny sygnifikatorów → wyszukiwanie w bazie.
Zalążek LOG-15/16: z pozycji obiektów (wraz z domami) generujemy tokeny w składni
bazy i wyszukujemy pasujące rekordy. Dla każdego obiektu tworzymy kilka **faset**:
- „w znaku" — planeta + token znaku (`[Su` + `[Tau`)
- „w domu" — planeta + token domu (`[Su` + `11th H.`)
Aspekty (`[conj`,`[sq`,`[opp`) wymagają policzenia aspektów (LOG-06) — na później.
Format skrótów odczytany z realnej bazy: planety `[Su`,`[Mo`,…; znaki
`[Ari`,`[Tau`,…; domy `12th H.`; np. `[Sa [conj [Su in 6th H.`.
"""
from __future__ import annotations
from typing import Any, Protocol
from app.abbreviations import expand
from app.engine.formats import SIGN_ABBR, SIGNS
from app.scoring import score_facet
PLANET_ABBR = {
"Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma",
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
"North Node": "NN", "South Node": "SN", "Lilith": "Lilith",
# Lots — w bazie Fortuna występuje jako [PF (Part of Fortune)
"Fortune": "PF", "Spirit": "Spirit",
}
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
class DataSource(Protocol):
def search(
self, key: str, value: str, exact: bool, limit: int, fields: list[str] | None = None
) -> dict[str, Any]: ...
def _effect(row: dict) -> str:
for col in ("actioneffect", "topicresult", "bodypart"):
v = row.get(col)
if v and str(v).strip().lower() not in ("", "nan"):
return str(v).strip()
return ""
def _is_noise(sig: str, effect: str) -> bool:
s = sig.strip().lower()
e = effect.strip().lower()
return (
e in ("", "nan", "x", "x?", "?", "-") # efekt pusty/zastępczy
or s.startswith("significator") # wiersz-legenda/nagłówek
or "header" in s
or s.startswith("*") # *MARKER / *header
or s in ("x", "x?", "nan") # znacznik „pomiń rekord"
)
def _ordinal(n: int) -> str:
if 10 <= n % 100 <= 20:
suffix = "th"
else:
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"
def _norm(s: str) -> str:
"""Normalizacja do porównań duplikatów: bez skrajnych spacji, jedna spacja, lower."""
return " ".join(str(s).strip().lower().split())
def _facet_samples(rows: list[dict], tokens: list[str]) -> list[dict]:
"""Rekordy, których sygnifikator zawiera WSZYSTKIE tokeny — bez szumu i bez duplikatów.
Duplikat = ten sam sygnifikator ORAZ ten sam opis (po normalizacji). Dedup
działa na zagregowanym wyniku, więc odsiewa też powtórki między wieloma bazami.
"""
toks = [t.lower() for t in tokens if t]
out: list[dict] = []
seen: set[tuple[str, str]] = set()
for r in rows:
sig = str(r.get("significator") or "").strip()
low = sig.lower()
if not all(t in low for t in toks):
continue
eff = _effect(r)
if _is_noise(sig, eff):
continue
key = (_norm(sig), _norm(eff))
if key in seen:
continue
seen.add(key)
out.append({"significator": sig, "expanded": expand(sig), "effect": eff})
return out
def _group_by_effect(samples: list[dict]) -> list[dict]:
"""Grupuje próbki po opisie: ten sam efekt = jedna grupa z listą sygnifikatorów."""
groups: dict[str, dict] = {}
order: list[str] = []
for s in samples:
key = _norm(s["effect"])
g = groups.get(key)
if g is None:
g = {"effect": s["effect"], "count": 0, "significators": []}
groups[key] = g
order.append(key)
g["count"] += 1
g["significators"].append(s["expanded"])
result = [groups[k] for k in order]
result.sort(key=lambda g: g["count"], reverse=True)
return result
def build_report(
positions: list[dict],
data: DataSource,
aspects: list[dict] | None = None,
per_object_limit: int = 5000,
group: bool = False,
) -> dict:
"""positions: pozycje z build_chart (name, sign, direction, house).
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
"""
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
items: list[dict] = []
provider = None
for p in positions:
name = p.get("name")
if name not in PLANET_ABBR:
continue
planet_tok = "[" + PLANET_ABBR[name]
raw = data.search(
key="significator",
value=planet_tok,
exact=False,
limit=per_object_limit,
fields=["significator", "actioneffect", "topicresult", "bodypart"],
)
provider = raw.get("provider", provider)
rows = raw.get("rows", [])
facets: list[dict] = []
sign = p.get("sign")
sign_tok = "[" + SIGN_TO_ABBR.get(sign, "")
sign_samples = _facet_samples(rows, [sign_tok])
facets.append({
"type": "sign", "label": f"w znaku {sign}", "token": sign_tok,
"count": len(sign_samples), "samples": sign_samples,
})
house = p.get("house")
if house:
ordn = _ordinal(int(house))
house_samples = _facet_samples(rows, [f"{ordn} h"]) # matcuje '12th H.'
facets.append({
"type": "house", "label": f"w {ordn} domu", "token": f"{ordn} H.",
"count": len(house_samples), "samples": house_samples,
})
for asp in (aspects or []):
if name not in (asp.get("obj1"), asp.get("obj2")):
continue
other = asp["obj2"] if asp["obj1"] == name else asp["obj1"]
asp_tok = ASP_TOKEN.get(asp["aspect"])
if other not in PLANET_ABBR or not asp_tok:
continue
other_tok = "[" + PLANET_ABBR[other]
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
continue
as_suffix = f" ({asp['as']})" if asp.get("as") else ""
facets.append({
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}{as_suffix}",
"token": f"{asp_tok} + {other_tok}",
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
"applying": asp.get("applying"),
"count": len(asp_samples), "samples": asp_samples,
})
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
for f in facets:
f["score"] = score_facet(f)
if group:
f["groups"] = _group_by_effect(f["samples"])
facets.sort(key=lambda f: f["score"], reverse=True)
items.append({
"object": name,
"sign": sign,
"house": house,
"direction": p.get("direction"),
"planet_token": planet_tok,
"planet_total": raw.get("total", 0),
"facets": facets,
})
return {"provider": provider, "objects": items}
def _event_tokens(event: dict) -> list[str]:
"""Tokeny bazy dla zdarzenia osi czasu (spięcie 1B→2B). Pierwszy = planeta."""
from app.engine.aspects import DB_TOKEN
if event.get("technique") == "solar_arc":
p = PLANET_ABBR.get(event.get("directed"))
a = DB_TOKEN.get(event.get("aspect"))
q = PLANET_ABBR.get(event.get("target")) # None dla Asc/MC (nie ma tokenu)
toks = []
if p:
toks.append("[" + p)
if a:
toks.append(a)
if q:
toks.append("[" + q)
return toks if p else []
if event.get("technique") == "profection":
lord = PLANET_ABBR.get(event.get("lord"))
sign = SIGN_TO_ABBR.get(event.get("sign"))
toks = []
if lord:
toks.append("[" + lord)
if sign:
toks.append("[" + sign)
return toks if lord else []
if event.get("technique") == "firdaria":
major = PLANET_ABBR.get(event.get("fd_major"))
sub = PLANET_ABBR.get(event.get("fd_sub"))
toks = []
if major:
toks.append("[" + major)
if sub:
toks.append("[" + sub)
return toks if major else []
return []
def interpret_events(events: list[dict], data: DataSource, limit: int = 4,
per_object_limit: int = 5000) -> list[dict]:
"""Dopina interpretacje z bazy do zdarzeń osi czasu (predykcyjne 1B → 2B).
Wyszukuje po tokenie planety zdarzenia i zawęża do wszystkich tokenów
(aspekt/druga planeta lub znak), z odsiewaniem szumu i duplikatów.
"""
for ev in events:
tokens = _event_tokens(ev)
if not tokens:
continue
raw = data.search(
key="significator", value=tokens[0], exact=False, limit=per_object_limit,
fields=["significator", "actioneffect", "topicresult", "bodypart"],
)
samples = _facet_samples(raw.get("rows", []), tokens)
ev["interpretations"] = samples[:limit]
ev["interpretations_count"] = len(samples)
return events
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0
+2
View File
@@ -2,3 +2,5 @@ fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
pydantic>=2.10
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
skyfield>=1.49
+36
View File
@@ -0,0 +1,36 @@
"""Wspólne fixture'y testów silnika.
Horoskop referencyjny = przykład z notatek projektu (notes3): 30.04.1984,
09:35 CEST = 07:35 UTC, Warszawa. Pozycje znane z astro.com/Swiss Ephemeris
służą jako wyrocznia (LOG-25).
"""
from __future__ import annotations
import datetime as dt
import os
import pytest
from app.engine.models import ChartMoment
REFERENCE_UTC = dt.datetime(1984, 4, 30, 7, 35, 0, tzinfo=dt.timezone.utc)
@pytest.fixture(scope="session")
def reference_moment() -> ChartMoment:
return ChartMoment(when_utc=REFERENCE_UTC, lat=52.2333, lon=21.0167)
@pytest.fixture(scope="session")
def own_engine():
pytest.importorskip("skyfield")
from app.engine.skyfield_engine import SkyfieldEngine
try:
return SkyfieldEngine()
except Exception as e: # brak efemeryd / brak sieci
# Lokalnie pomijamy (dev bez pobranego jądra), ale w CI to musi być błąd —
# inaczej testy referencyjne (walidacja względem astro.com) cicho znikają.
if os.getenv("CI"):
pytest.fail(f"CI: silnik efemeryd niedostępny — testy referencyjne muszą działać: {e}")
pytest.skip(f"Nie można wczytać efemeryd: {e}")
@@ -0,0 +1,22 @@
"""Testy rozwijania skrótów sygnifikatorów."""
from app.abbreviations import expand
def test_planet_in_sign():
assert expand("[Su in [Tau") == "Sun in Taurus"
def test_aspect_and_house():
assert expand("[Sa [conj [Su in 6th H.") == "Saturn conjunction Sun in 6th house"
def test_affliction_word_and_house():
assert expand("[Ne affl. in the 7th H.") == "Neptune afflicted in the 7th house"
def test_unknown_token_kept_without_bracket():
assert expand("[Xyz rising") == "Xyz rising"
def test_empty_passthrough():
assert expand("") == ""
+112
View File
@@ -0,0 +1,112 @@
"""Testy aspektów (LOG-06) — czysta matematyka."""
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
def test_separation_wraparound():
assert separation(10, 350) == 20
assert separation(0, 180) == 180
assert separation(0, 90) == 90
def test_conjunction_and_opposition():
pos = [
{"name": "Sun", "decimal": 10.0},
{"name": "Moon", "decimal": 12.0}, # 2° od Słońca -> koniunkcja
{"name": "Mars", "decimal": 190.0}, # 180° od Słońca -> opozycja
]
pairs = {(a["obj1"], a["obj2"], a["aspect"]) for a in find_aspects(pos)}
assert ("Sun", "Moon", "conjunction") in pairs
assert ("Sun", "Mars", "opposition") in pairs
def test_orb_limit_excludes_wide():
pos = [{"name": "Mercury", "decimal": 0.0}, {"name": "Venus", "decimal": 100.0}]
assert find_aspects(pos, orb=8.0, luminary_bonus=0.0) == []
def test_luminary_bonus_widens_orb():
# 99.5° -> 9.5° od kwadratury; z bonusem luminarza (8+2) mieści się
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Saturn", "decimal": 99.5}]
assert any(a["aspect"] == "square" for a in find_aspects(pos))
def test_one_aspect_per_pair():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert len(find_aspects(pos)) == 1
def test_applying_when_faster_body_catches_up():
# Księżyc 5° za Słońcem, szybszy -> koniunkcja aplikacyjna
pos = [
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
{"name": "Moon", "decimal": 35.0, "speed": 13.0},
]
a = find_aspects(pos)[0]
assert a["applying"] is True and a["as"] == "A"
def test_separating_when_moving_apart():
# Księżyc 5° przed Słońcem i szybszy -> koniunkcja separacyjna
pos = [
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
{"name": "Moon", "decimal": 45.0, "speed": 13.0},
]
a = find_aspects(pos)[0]
assert a["applying"] is False and a["as"] == "S"
def test_rigid_pair_nodes_not_reported():
# SN = NN + 180° z definicji — trywialna opozycja, nie aspekt
pos = [
{"name": "North Node", "decimal": 42.0, "speed": -0.053},
{"name": "South Node", "decimal": 222.0, "speed": -0.053},
{"name": "Sun", "decimal": 42.5, "speed": 0.96},
]
pairs = {frozenset((a["obj1"], a["obj2"])) for a in find_aspects(pos)}
assert frozenset({"North Node", "South Node"}) not in pairs
# aspekty węzłów do innych obiektów zostają nietknięte
assert frozenset({"Sun", "North Node"}) in pairs
assert frozenset({"Sun", "South Node"}) in pairs
def test_no_rigid_pairs_in_real_chart(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
found = [a for a in chart["aspects"] if frozenset((a["obj1"], a["obj2"])) in RIGID_PAIRS]
assert not found, f"trywialne aspekty par sztywnych w horoskopie: {found}"
def test_no_as_flag_without_speeds():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert "as" not in find_aspects(pos)[0]
# Referencja A/S z notes3 (astro-seek) dla horoskopu 30.04.1984 07:35 UT, Warszawa.
REFERENCE_AS = {
("Sun", "Moon", "conjunction"): "A",
("Sun", "Jupiter", "trine"): "A",
("Sun", "Saturn", "opposition"): "A",
("Sun", "Neptune", "trine"): "S",
("Sun", "Pluto", "opposition"): "S",
("Moon", "Mercury", "conjunction"): "S",
("Moon", "Venus", "conjunction"): "S",
("Moon", "Neptune", "trine"): "A",
("Moon", "Pluto", "opposition"): "S",
("Mercury", "Venus", "conjunction"): "S",
("Mercury", "Neptune", "trine"): "S",
("Mercury", "Pluto", "opposition"): "S",
("Venus", "Neptune", "trine"): "A",
("Venus", "Pluto", "opposition"): "A",
("Jupiter", "Saturn", "sextile"): "A",
("Neptune", "Pluto", "sextile"): "S",
}
def test_applying_separating_matches_astroseek_reference(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
got = {(a["obj1"], a["obj2"], a["aspect"]): a.get("as") for a in chart["aspects"]}
mismatches = {k: (got.get(k), v) for k, v in REFERENCE_AS.items() if got.get(k) != v}
assert not mismatches, f"rozbieżności A/S vs astro-seek: {mismatches}"
+27
View File
@@ -0,0 +1,27 @@
"""Integracja: pełny horoskop (pozycje + osie + domy) przez silnik (LOG-01+LOG-05).
Waliduje względem astro.com dla horoskopu referencyjnego.
"""
from app.engine.chart import build_chart
def test_chart_angles_match_reference(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, "whole_sign")
assert chart["angles"]["Asc"]["sign"] == "Cancer"
assert chart["angles"]["MC"]["sign"] == "Pisces"
def test_chart_house_assignments_match_reference(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, "whole_sign")
by = {p["name"]: p for p in chart["positions"]}
expected = {"Sun": 11, "Moon": 11, "Mercury": 10, "Venus": 10, "Mars": 5,
"Jupiter": 7, "Saturn": 5, "Uranus": 6, "Neptune": 7, "Pluto": 5}
for name, house in expected.items():
assert by[name]["house"] == house, f"{name}: dom {by[name]['house']} != {house}"
def test_house_systems_available(own_engine, reference_moment):
for system in ("whole_sign", "equal", "porphyry"):
chart = build_chart(own_engine, reference_moment, system)
assert chart["house_system"] == system
assert len(chart["cusps"]) == 12
+35
View File
@@ -0,0 +1,35 @@
"""Testy harnessu porównawczego (LOG-25) — bez efemeryd."""
from app.engine.compare import compare_positions
from app.engine.models import ObjectPosition
def test_identical_positions_have_zero_diff():
a = [ObjectPosition("Sun", 40.0, 0.0, 0.95, False)]
report = compare_positions(a, a, lon_tol_arcsec=1.0)
assert report.ok
assert report.max_arcsec == 0.0
def test_offset_beyond_tolerance_is_flagged():
a = [ObjectPosition("Sun", 40.0, 0.0, 0.95, False)]
b = [ObjectPosition("Sun", 40.1, 0.0, 0.95, False)] # 0.1° = 360"
report = compare_positions(a, b, lon_tol_arcsec=120.0)
assert not report.ok
assert report.diffs[0].over_tolerance
assert abs(abs(report.diffs[0].delta_arcsec) - 360.0) < 1.0
def test_retrograde_mismatch_is_flagged():
a = [ObjectPosition("Mars", 234.0, 0.0, -0.2, True)]
b = [ObjectPosition("Mars", 234.0, 0.0, 0.2, False)]
report = compare_positions(a, b, lon_tol_arcsec=120.0)
assert not report.ok
assert report.diffs[0].speed_sign_mismatch
def test_wraparound_delta_is_small():
a = [ObjectPosition("Sun", 359.99, 0.0, 1.0, False)]
b = [ObjectPosition("Sun", 0.01, 0.0, 1.0, False)]
report = compare_positions(a, b, lon_tol_arcsec=120.0)
assert report.ok # 0.02° ≈ 72" < 120"
assert abs(report.diffs[0].delta_arcsec) < 80.0
@@ -0,0 +1,25 @@
"""Kontrakt parzystości silników (LOG-28).
Ten sam test musi przejść dla KAŻDEGO silnika. Dziś uruchamiamy go dla silnika
własnego; gdy skonfigurowany jest ENGINE_SWISSEPH_URL, ten sam kontrakt sprawdza
też silnik B.
"""
import os
import pytest
from app.engine.compare import check_engine_contract
def test_own_engine_contract(own_engine, reference_moment):
check_engine_contract(own_engine, reference_moment)
@pytest.mark.skipif(
not os.getenv("ENGINE_SWISSEPH_URL"),
reason="silnik B (swisseph) nieskonfigurowany — pomijam",
)
def test_swisseph_engine_contract(reference_moment):
from app.engine.remote_engine import RemoteEngine
check_engine_contract(RemoteEngine(), reference_moment)
+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"]
+28
View File
@@ -0,0 +1,28 @@
"""Testy formatowania (czyste, bez efemeryd)."""
from app.engine import formats
def test_sign_index():
assert formats.sign_index(0) == 0 # Aries
assert formats.sign_index(35) == 1 # Taurus
assert formats.sign_index(359.9) == 11 # Pisces
def test_in_sign_basic():
# 40°08'21" absolutnie = Taurus 10°08'21"
assert formats.in_sign(40.139166) == "Tau 10°08'21\""
def test_absolute():
assert formats.absolute(40.139166) == "40°08'21\""
def test_wraparound_and_norm():
assert formats.norm360(370.0) == 10.0
assert formats.norm360(-1.0) == 359.0
def test_rounding_carry_into_next_sign():
# tuż przy granicy znaku zaokrąglenie nie może dać "30°"
s = formats.in_sign(59.99999)
assert s.startswith("Gem 0°") or s.startswith("Tau 29°59")
+41
View File
@@ -0,0 +1,41 @@
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
from app.engine import houses as H
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
RAMC, EPS, LAT = 353.1968, 23.44133, 52.2333
def _near(a, b, tol=0.05):
return abs(((a - b + 180) % 360) - 180) < tol
def test_asc_mc_match_reference():
asc = H.compute_asc(RAMC, EPS, LAT)
mc = H.compute_mc(RAMC, EPS)
assert _near(asc, 112.18) # Cancer 22°10' (astro.com)
assert _near(mc, 352.59) # Pisces 22°35'
def test_whole_sign_starts_on_sign_boundary():
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN)
assert cusps[0] == 90.0 # dom 1 = 0° Raka
assert cusps[1] == 120.0
def test_equal_cusps_are_30_apart_from_asc():
cusps = H.cusps(112.18, 352.59, H.EQUAL)
assert abs(cusps[0] - 112.18) < 1e-9
assert abs(cusps[1] - 142.18) < 1e-9
def test_porphyry_angles_on_cusps():
cusps = H.cusps(112.18, 352.59, H.PORPHYRY)
assert abs(cusps[0] - 112.18) < 1e-9 # Asc = dom 1
assert abs(cusps[9] - 352.59) < 1e-9 # MC = dom 10
assert abs(cusps[6] - (112.18 + 180) % 360) < 1e-9 # Dsc = dom 7
def test_assign_house_whole_sign():
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90120°)
assert H.assign_house(100.0, cusps) == 1 # w Raku
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
+149
View File
@@ -0,0 +1,149 @@
"""Dostawcy LLM (LOG-31) — bez wołania jakiegokolwiek prawdziwego modelu.
Transport podstawiamy przez httpx.MockTransport, więc testy szybkie,
deterministyczne i nic nie wychodzi na zewnątrz.
"""
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="LLM_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)
with pytest.raises(LLMError, match="LLM_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"
+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)
+20
View File
@@ -0,0 +1,20 @@
"""Testy punktacji siły faset (LOG-21)."""
from app.scoring import score_facet
def test_sign_and_house_base():
assert score_facet({"type": "sign"}) == 5.0
assert score_facet({"type": "house"}) == 5.0
def test_tighter_aspect_scores_higher():
tight = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 0.0, "allowed": 10.0})
loose = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 9.0, "allowed": 10.0})
assert tight > loose
assert tight == 12.0 # 6.0 * 1.0 * (1 + 1)
def test_conjunction_beats_sextile_at_same_orb():
conj = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 2.0, "allowed": 10.0})
sext = score_facet({"type": "aspect", "aspect": "sextile", "orb": 2.0, "allowed": 10.0})
assert conj > sext
+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
@@ -0,0 +1,93 @@
"""Testy mostu obliczenia → sygnifikatory → wyszukiwanie (bez efemeryd/HTTP)."""
from app.engine.models import DEFAULT_OBJECTS
from app.significators import PLANET_ABBR, _ordinal, build_report
class FakeData:
def __init__(self, rows_by_value: dict) -> None:
self.rows_by_value = rows_by_value
def search(self, key, value, exact, limit, fields=None) -> dict:
rows = self.rows_by_value.get(value, [])
return {"provider": "fake", "total": len(rows), "rows": rows}
def test_sign_and_house_facets():
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
data = FakeData({"[Su": [
{"significator": "[Su in [Tau", "actioneffect": "efekt znak"}, # faseta znak
{"significator": "[Su in 11th H.", "actioneffect": "efekt dom"}, # faseta dom
{"significator": "[Su in [Vir", "actioneffect": "inny"}, # żadna
{"significator": "[Su in [Tau", "actioneffect": "nan"}, # szum
]})
item = build_report(positions, data)["objects"][0]
assert item["object"] == "Sun" and item["house"] == 11
facets = {f["type"]: f for f in item["facets"]}
assert facets["sign"]["count"] == 1
assert facets["sign"]["samples"][0]["effect"] == "efekt znak"
assert facets["sign"]["samples"][0]["expanded"] == "Sun in Taurus" # rozwinięcie skrótu
assert facets["house"]["count"] == 1
assert facets["house"]["token"] == "11th H."
assert facets["house"]["samples"][0]["effect"] == "efekt dom"
def test_no_house_facet_when_house_missing():
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx"}]
data = FakeData({"[Ma": [{"significator": "[Ma in [Sco", "actioneffect": "eff"}]})
types = [f["type"] for f in build_report(positions, data)["objects"][0]["facets"]]
assert "sign" in types and "house" not in types
def test_dedup_by_significator_and_effect():
positions = [{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11}]
data = FakeData({"[Mo": [
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"},
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"}, # duplikat (sig+opis)
{"significator": "[Mo in 11th H.", "actioneffect": "inny efekt"}, # ten sam sig, inny opis
]})
facets = {f["type"]: f for f in build_report(positions, data)["objects"][0]["facets"]}
assert facets["house"]["count"] == 2 # duplikat odsiany, różny opis zostaje
def test_aspect_facet():
positions = [
{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11},
{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11},
]
aspects = [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.0}]
data = FakeData({"[Su": [{"significator": "[Su [conj [Mo", "actioneffect": "złączeni"}]})
sun = build_report(positions, data, aspects=aspects)["objects"][0]
asp = [f for f in sun["facets"] if f["type"] == "aspect"]
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
def test_grouping_by_effect():
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
data = FakeData({"[Ma": [
{"significator": "[Ma in 5th H.", "actioneffect": "miscarriage"},
{"significator": "[Sa in 5th H.", "actioneffect": "miscarriage"}, # inny sig, ten sam opis
{"significator": "[Ma in 5th H.", "actioneffect": "fever"},
]})
house = [f for f in build_report(positions, data, group=True)["objects"][0]["facets"]
if f["type"] == "house"][0]
groups = {g["effect"]: g for g in house["groups"]}
assert groups["miscarriage"]["count"] == 2
assert len(groups["miscarriage"]["significators"]) == 2
assert "score" in house
def test_facets_carry_score():
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
data = FakeData({"[Su": [{"significator": "[Su in [Tau", "actioneffect": "e"}]})
facets = build_report(positions, data)["objects"][0]["facets"]
assert all("score" in f for f in facets)
def test_ordinal():
assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd"
assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th"
def test_planet_abbr_covers_all_default_objects():
for o in DEFAULT_OBJECTS:
assert o in PLANET_ABBR
@@ -0,0 +1,44 @@
"""Walidacja silnika własnego względem wyroczni (LOG-01 + LOG-25).
Porównujemy pozycje policzone Skyfieldem ze znanymi wartościami Swiss Ephemeris
(astro.com) dla horoskopu referencyjnego. To dokładnie mechanizm LOG-25 tyle
że wyrocznią tu zapisane wcześniej wartości referencyjne zamiast żywej usługi
swisseph. Tolerancja 2' (120") z zapasem na różnicę barycentrum/centrum planety.
"""
from app.engine.compare import compare_positions
from app.engine.models import ObjectPosition
# obiekt -> (długość absolutna w stopniach, retrogradacja) — Swiss Ephemeris / astro.com
REFERENCE = {
"Sun": (40.139167, False), # Tau 10°08'21"
"Moon": (30.551389, False), # Tau 0°33'05"
"Mercury": (27.383889, True), # Ari 27°23'02" Rx
"Venus": (27.676944, False), # Ari 27°40'37"
"Mars": (234.541111, True), # Sco 24°32'28" Rx
"Jupiter": (282.961667, True), # Cap 12°57'42" Rx
"Saturn": (223.310833, True), # Sco 13°18'39" Rx
"Uranus": (252.813333, True), # Sag 12°48'48" Rx
"Neptune": (271.220833, True), # Cap 1°13'15" Rx
"Pluto": (210.478889, True), # Sco 0°28'44" Rx
}
TOLERANCE_ARCSEC = 120.0
def _reference_positions():
return [
ObjectPosition(name, lon, 0.0, (-1.0 if retro else 1.0), retro)
for name, (lon, retro) in REFERENCE.items()
]
def test_positions_match_swisseph_reference(own_engine, reference_moment):
got = own_engine.positions(reference_moment)
report = compare_positions(got, _reference_positions(), lon_tol_arcsec=TOLERANCE_ARCSEC)
assert report.ok, report.summary()
def test_retrograde_flags_match(own_engine, reference_moment):
got = {p.name: p for p in own_engine.positions(reference_moment)}
for name, (_lon, retro) in REFERENCE.items():
assert got[name].retrograde == retro, f"{name}: kierunek niezgodny"
+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
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
+12 -3
View File
@@ -5,12 +5,21 @@ przekazuje dane z formularza do warstwy logicznej i renderuje opracowane wyniki.
Brak logiki biznesowej i dostępu do danych.
## Trasy
- `GET /`strona z formularzem
- `POST /`wysłanie formularza → warstwa logiczna → render wyników
- `GET /`**Horoskop**: formularz danych momentu (data, godzina, strefa, opcjonalnie lokalizacja)
- `POST /`liczy pozycje obiektów (woła logic `/chart/positions`) i renderuje czytelną tabelę
- `GET /significators`, `POST /significators` — wyszukiwarka w bazach interpretacji (warstwa danych)
- `GET /health`
Strona „Horoskop" to widok do **ręcznego testowania**: podstawowe dane wejściowe →
to, co policzyła warstwa logiczna (znak, pozycja w znaku, absolutna, kierunek, prędkość).
Przycisk „Tu i teraz" uzupełnia bieżącą datę/godzinę i strefę przeglądarki.
## Zależności w dół
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej).
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej) i jej kontrakty `/chart/positions`
oraz `/api/query`. Nie wie nic o silniku ani bazach.
> Uwaga: pełne liczenie pozycji wymaga warstwy logicznej z silnikiem efemeryd
> (gałąź `feat/logic-engine`). Bez niego strona „Horoskop" pokaże czytelny komunikat.
## Uruchomienie
```bash
@@ -1,10 +1,11 @@
"""Klient HTTP do warstwy logicznej.
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy.
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
@@ -12,6 +13,12 @@ import httpx
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 {}
class LogicClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
@@ -19,6 +26,94 @@ class LogicClient:
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 = client.post(f"{self.base_url}/api/query", json=payload, headers=_auth_headers())
r.raise_for_status()
return r.json()
def positions(
self,
when_utc_iso: str,
lat: float,
lon: float,
objects: list[str] | None = None,
house_system: str = "whole_sign",
stations: bool = False,
zodiac: str = "tropical",
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
"when_utc": when_utc_iso,
"lat": lat,
"lon": lon,
"objects": objects,
"house_system": house_system,
"stations": stations,
"zodiac": zodiac,
}
# stacje wymagają root-findów — dłuższy timeout
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
r = client.post(f"{self.base_url}/chart/positions", json=payload, headers=_auth_headers())
r.raise_for_status()
return r.json()
def report(
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
) -> dict[str, Any]:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/chart/report", json=payload, headers=_auth_headers())
r.raise_for_status()
return r.json()
def prompt(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
) -> dict[str, Any]:
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/prompt", json=payload)
r.raise_for_status()
return r.json()
def horoscope(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", provider: str | None = None,
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 from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
r = client.post(f"{self.base_url}/chart/horoscope", json=payload)
r.raise_for_status()
return r.json()
def timeline(
self, when_utc_iso: str, lat: float, lon: float,
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,
}
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/timeline", json=payload, headers=_auth_headers())
r.raise_for_status()
return r.json()
+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
+194 -6
View File
@@ -2,30 +2,99 @@
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych tylko UI.
Strona główna /" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
/significators".
"""
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]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny offset.
"""
local = datetime.fromisoformat(f"{date}T{time}")
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
label = utc.strftime("%Y-%m-%d %H:%M UTC")
return utc.isoformat(), label
def _logic_error(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
return (
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
)
return f"Warstwa logiczna niedostępna: {e}"
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse(request, "index.html", {"result": None, "form": {}})
def chart_form(request: Request):
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/", response_class=HTMLResponse)
def search(
def chart_compute(
request: Request,
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
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, "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, stations=stations, zodiac=zodiac,
)
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, "chart.html", ctx)
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
@app.get("/significators", response_class=HTMLResponse)
def significators_form(request: Request):
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
@app.post("/significators", response_class=HTMLResponse)
def significators_search(
request: Request,
query: str = Form(...),
field: str = Form("name"),
@@ -37,8 +106,127 @@ def search(
try:
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
except httpx.HTTPError as e:
ctx["error"] = f"Warstwa logiczna niedostępna: {e}"
return templates.TemplateResponse(request, "index.html", ctx)
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "significators.html", ctx)
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
@app.get("/interpret", response_class=HTMLResponse)
def interpret_form(request: Request):
return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/interpret", response_class=HTMLResponse)
def interpret_run(
request: Request,
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
group: bool = Form(False),
action: str = Form("report"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
):
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
"llm_provider": llm_provider}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
if action == "prompt":
ctx["prompt_result"] = logic.prompt(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
)
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,
)
else:
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "interpret.html", ctx)
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
@app.get("/timeline", response_class=HTMLResponse)
def timeline_form(request: Request):
return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@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"),
):
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}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
if action == "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,
)
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,
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")
+120
View File
@@ -0,0 +1,120 @@
"""Ochrona dostępu do aplikacji (LOG-32).
Rdzeniem produktu oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
uwierzytelnienia oznacza, że każdy w sieci może je wypompować bez udziału
jakiegokolwiek modelu językowego. Ten moduł zamyka drogę.
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ń).
Ś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"))
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 _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)
client = request.client.host if request.client else "?"
if _rate_limited(client):
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));
});
});
})();
+44
View File
@@ -0,0 +1,44 @@
// „Tu i teraz": uzupełnia datę/godzinę/strefę z przeglądarki oraz — jeśli to
// możliwe — lokalizację (lat/lon).
//
// UWAGA: geolokalizacja przeglądarki działa tylko w "secure context"
// (https:// lub localhost). Na http://<ip> przeglądarka NIE pyta o zgodę,
// tylko po cichu odmawia — dlatego pokazujemy jawny komunikat w #geoNote.
document.addEventListener('DOMContentLoaded', function () {
const btn = document.getElementById('nowBtn');
if (!btn) return;
const note = document.getElementById('geoNote');
const say = msg => { if (note) note.textContent = msg; };
btn.addEventListener('click', function () {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
document.querySelector('input[name=date]').value =
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
if (!('geolocation' in navigator)) {
say('Ta przeglądarka nie udostępnia geolokalizacji — wpisz lat/lon ręcznie.');
return;
}
if (!window.isSecureContext) {
say('Lokalizacja z przeglądarki wymaga HTTPS lub localhost (otwarto przez http://) — wpisz lat/lon ręcznie.');
return;
}
say('Pobieram lokalizację…');
navigator.geolocation.getCurrentPosition(
function (pos) {
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
document.dispatchEvent(new CustomEvent('astrololo:coords')); // zsynchronizuj mapę (geo.js)
say('Lokalizacja pobrana ✓');
},
function (err) {
say('Nie udało się pobrać lokalizacji: ' + (err && err.message ? err.message : 'odmowa dostępu'));
},
{ timeout: 8000 }
);
});
});
@@ -15,6 +15,23 @@ body {
main { width: 100%; max-width: 880px; }
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
.topbar nav { display: flex; gap: .25rem; }
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
.topbar nav a:hover { color: var(--ink); }
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
@@ -33,3 +50,41 @@ table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: v
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
tr:last-child td { border-bottom: none; }
.muted { color: var(--muted); }
.small { font-size: .85rem; }
.nowrap { white-space: nowrap; }
.sig-item { margin-top: 1.25rem; }
.sig-head { padding: .5rem 0; }
.samples { margin-top: .3rem; }
.samples td { font-size: .92rem; vertical-align: top; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.facet { margin: .4rem 0 .6rem 1rem; }
.facet-head { color: var(--ink); font-size: .9rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
/* Generator promptu do LLM (LOG-29/30) */
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
background: #12132a; color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
@@ -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,86 @@
{# 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>
</select>
</label>
<label>Model
<select name="llm_provider">
{% set lp = form.llm_provider or 'local' %}
<option value="local" {{ 'selected' if lp == 'local' else '' }}>lokalny — nic nie opuszcza sieci</option>
<option value="anthropic" {{ 'selected' if lp == 'anthropic' else '' }}>Anthropic — dane wychodzą</option>
<option value="openai" {{ 'selected' if lp == 'openai' else '' }}>OpenAI — dane wychodzą</option>
</select>
</label>
</div>
<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.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 %}
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main>
<header class="topbar">
<h1>astrololo</h1>
<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>
{% block content %}{% endblock %}
<footer class="foot">
prezentacja → logika → dane · widok testowy
</footer>
</main>
</body>
</html>
@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block title %}Horoskop{% endblock %}
{% block nav_chart %}active{% endblock %}
{% block content %}
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
<form method="post" action="/">
<div class="grid">
<label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required>
</label>
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
</div>
<div class="grid">
<label>Szerokość (lat, + N)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
</label>
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
</select>
</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>
<span id="geoNote" class="muted small"></span>
</div>
</form>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="meta">
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>
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<table>
<thead>
<tr>
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
</tr>
</thead>
<tbody>
{% for p in result.positions %}
<tr>
<td>{{ p.name }}</td>
<td>{{ p.sign }}</td>
<td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.lots %}
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
<table class="angles">
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
<tbody>
{% for l in result.lots %}
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
{% if with_stations %}
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
<table class="angles">
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
<tbody>
{% for p in with_stations %}
<tr>
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.aspects %}
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody>
{% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.cusps %}
<details class="loc">
<summary>Cusps domów ({{ result.house_system }})</summary>
<table>
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
<tbody>
{% for c in result.cusps %}
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% endif %}
<script src="/static/now.js"></script>
{% endblock %}
@@ -1,62 +0,0 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main>
<h1>astrololo</h1>
<p class="sub">Warstwa prezentacji → logiczna → bazodanowa</p>
<form method="post" action="/">
<div class="row">
<input type="text" name="query" placeholder="Szukana fraza…"
value="{{ form.query or '' }}" autofocus required>
<select name="field">
{% for f in ["name", "id", "symbol", "category", "value"] %}
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
{% endfor %}
</select>
<button type="submit">Szukaj</button>
</div>
<div class="opts">
<label><input type="checkbox" name="exact" value="true"
{{ 'checked' if form.exact else '' }}> dokładne</label>
<label>limit
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
</label>
</div>
</form>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="meta">
Znaleziono <strong>{{ result.count }}</strong> ·
provider: {{ result.meta.data_provider }} ·
cache: {{ result.meta.data_cache }} ·
{{ result.meta.data_elapsed_ms }} ms
</div>
{% if result.results %}
<table>
<thead>
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for row in result.results %}
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Brak wyników.</p>
{% endif %}
{% endif %}
</main>
</body>
</html>
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}Interpretacje{% endblock %}
{% block nav_interp %}active{% endblock %}
{% block content %}
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
<form method="post" action="/interpret">
<div class="grid">
<label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required>
</label>
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label>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>
{% 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" 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>
{% 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>
{% if result.provider %}· baza: {{ result.provider }}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %}
</div>
{% for o in result.objects %}
<div class="sig-item">
<div class="sig-head">
<strong>{{ o.object }}</strong> w <strong>{{ o.sign }}</strong>{% if o.house %}, {{ o.house }}. dom{% endif %}
<span class="{{ 'retro' if o.direction == 'Rx' else '' }}">{{ o.direction }}</span>
<span class="muted small">(planeta {{ o.planet_token }}: {{ o.planet_total }} rekordów)</span>
</div>
{% for f in o.facets %}
<div class="facet">
<div class="facet-head">{{ f.label }} — <strong>{{ f.count }}</strong> dopasowań
<span class="badge" title="siła (LOG-21)">siła {{ f.score }}</span>
<span class="muted small">[{{ f.token }}]</span></div>
{% if f.groups %}
<table class="samples">
<tbody>
{% for g in f.groups %}
<tr>
<td>{{ g.effect }}{% if g.count > 1 %} <span class="badge">×{{ g.count }}</span>{% endif %}</td>
<td class="sig small">{{ g.significators | join('; ') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elif f.samples %}
<table class="samples">
<tbody>
{% for s in f.samples %}
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
{% endblock %}
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Sygnifikatory{% endblock %}
{% block nav_sig %}active{% endblock %}
{% block content %}
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
<form method="post" action="/significators">
<div class="row">
<input type="text" name="query" placeholder="Szukana fraza…"
value="{{ form.query or '' }}" autofocus required>
<select name="field">
{% for f in ["name", "id", "symbol", "category", "value"] %}
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
{% endfor %}
</select>
<button type="submit">Szukaj</button>
</div>
<div class="opts">
<label><input type="checkbox" name="exact" value="true"
{{ 'checked' if form.exact else '' }}> dokładne</label>
<label>limit
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
</label>
</div>
</form>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="meta">
Znaleziono <strong>{{ result.count }}</strong> ·
provider: {{ result.meta.data_provider }} ·
cache: {{ result.meta.data_cache }} ·
{{ result.meta.data_elapsed_ms }} ms
</div>
{% if result.results %}
<table>
<thead>
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for row in result.results %}
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Brak wyników.</p>
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,83 @@
{% 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>
{% endblock %}
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0
@@ -0,0 +1,133 @@
"""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))
View File