Compare commits

...

69 Commits

Author SHA1 Message Date
gitea 114b7eebdf feat(ui): okno postepu z logiem podczas pisania horoskopu
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m52s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 38s
Testy / Kontrola składni wszystkich warstw (push) Successful in 24s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m50s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 34s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s
Generowanie trwa minutami, a zwykly POST nie dawal zadnego sygnalu — aplikacja
wygladala na zawieszona. Teraz w trakcie pracy pojawia sie okno z logiem,
zegarem i spinnerem.

Log pokazuje RZECZYWISTE zdarzenia z serwera, nie udawany pasek postepu:
- app/progress.py — strumien NDJSON; praca leci w watku roboczym, generator
  odpompowuje kolejke, wiec zdarzenia docz w TRAKCIE pracy, nie na koncu;
  heartbeat co 10s, zeby proxy nie uznalo polaczenia za martwe,
- providers.generate(..., on_event) — raportuje kazda ture (start, czas trwania,
  liczba znakow, czy urwana), bo to tura trwa,
- POST /chart/horoscope/stream w logice + proxy /horoscope/stream w prezentacji.

Wynik: ostatnie zdarzenie niesie GOTOWY HTML wyrenderowany z tego samego
szablonu, ktory renderuje przeladowanie strony (_prompt_result.html wydzielony
z _prompt_block.html). Jedno zrodlo prawdy dla wygladu wyniku — okno wstawia go
bez przeladowania.

Degradacja: bez strumieniowania w przegladarce formularz idzie klasycznie
i wszystko dziala jak wczesniej, tylko bez okna. Blad polaczenia konczy sie
komunikatem w logu, nie cisza.

BLAD ZNALEZIONY PRZY TESCIE NA ZYWO: petla kontynuacji odejmowala od budzetu
ZAMOWIONY limit tury zamiast tokenow faktycznie wyprodukowanych — pierwsza tura
zjadala caly budzet, wiec urwana odpowiedz nigdy nie doczekala sie dokonczenia
i wracala do uzytkownika jako calosc. Naprawione i pokryte testem regresyjnym.

Testy: 176 passed / 1 skipped (logika) + 17 (prezentacja). Zweryfikowane na zywo
z wolna atrapa modelu: zdarzenia z poprawnymi czasami, okno z 11 liniami logu,
wynik wstawiony bez przeladowania strony.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:32:51 +00:00
gitea a2aabd37a5 feat(presentation): wyszukiwarka lokalizacji + mapa (OSM/Leaflet)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m39s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 28s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s
build / build (push) Successful in 57s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
QoL 2/2: pole „Szukaj miejsca" (nazwa/adres/POI) + interaktywna mapa na
formularzach horoskopu / interpretacji / kalendarza. Bez klucza API.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:32:07 +02:00
gitea de5d58958c feat(presentation): domyslna lokalizacja = Szpital Barlickiego, Lodz
build / build (push) Successful in 1m9s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m43s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 47s
Testy / Kontrola składni wszystkich warstw (push) Successful in 24s
QoL: formularze (horoskop / interpretacje / kalendarz) maja wstepnie
wpisana lokalizacje urodzenia wlasciciela — Szpital Barlickiego w Lodzi
(51.7739N, 19.4829E; potwierdzone reverse-geokodowaniem OSM: Kopcinskiego
22/28). Nie trzeba jej wpisywac za kazdym razem.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:16:24 +00:00
gitea ab6fc0722c ci(swisseph): osobny workflow budujacy obraz silnika B
build-swisseph / build (push) Successful in 4m13s
build / build (push) Successful in 1m45s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m52s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 1m13s
Testy / Kontrola składni wszystkich warstw (push) Successful in 39s
Buduje i pushuje obraz gitea/astrololo-engine-swisseph (tag SHA + latest).
Celowo oddzielony od build.yaml (data/logic/presentation) — odpala sie
tylko przy zmianach w services/engine-swisseph/**, wiec obraz AGPL nie
miesza sie do pipeline'u permisywnego produktu. workflow_dispatch pozwala
zbootstrapowac pierwszy obraz recznie.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:22:51 +02:00
gitea db3d1e5117 Merge pull request #15 from migatu/fix/geo-secure-context
Geolokalizacja: jawny komunikat gdy brak secure context (http)
2026-07-07 19:01:19 +02:00
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
gitea 561d95c7f4 Wymagania: kolumna "Po ludzku (bez żargonu)" — nietechniczne wyjaśnienia
Dodaje per-requirement, kompletnie nietechniczne tłumaczenie do trzech
arkuszy warstw (dane 24, logika 28, prezentacja 13) jako ostatnią kolumnę.
Dla osób nietechnicznych — m.in. współpracownika dostarczającego bazy —
żeby każdy wiersz był zrozumiały bez żargonu. Autofiltr rozszerzony.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:16:33 +02:00
gitea 4231f640b8 Merge pull request #3 from migatu/requirements/dual-engine
Wymagania: dwa silniki w warstwie logicznej (własny + AGPL) do porównań
2026-06-27 17:01:16 +02:00
gitea f86ab55b29 Merge pull request #2 from migatu/docs/requirements-spreadsheet
Dodaj arkusz wymagań projektu (model trójwarstwowy)
2026-06-27 16:53:29 +02:00
gitea 269c89152e Merge pull request #1 from migatu/scaffold/three-tier-app
Szkielet aplikacji trójwarstwowej (prezentacja / logika / dane)
2026-06-26 17:36:22 +02:00
gitea 16d35c16dc Szkielet aplikacji trójwarstwowej (prezentacja / logika / dane)
Trzy niezależne usługi FastAPI komunikujące się przez HTTP/JSON, każda
zna tylko adres warstwy bezpośrednio pod nią:

- presentation (:8000) — strona WWW + formularz
- logic (:8001) — reguły biznesowe, pośrednik
- data (:8002) — wyszukiwanie danych za interfejsem DataProvider

Warstwa danych: czytanie setek plików .xlsx z wykrywaniem nagłówka i
mapowaniem układu kolumn na schemat kanoniczny, z 4-poziomowym cache
(schemat L1, Parquet L2, wyniki zapytań L3, odwrócony indeks L4) i
unieważnianiem po odcisku pliku. Gotowa ścieżka migracji do SQL
(ingest/to_sql.py + SqlDataProvider, przełączane przez DATA_PROVIDER).

Zawiera docker-compose, Makefile, generator danych przykładowych.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:35:09 +02:00
139 changed files with 9756 additions and 1 deletions
+17
View File
@@ -0,0 +1,17 @@
# Skopiuj do .env i dostosuj.
# --- warstwa bazodanowa ---
DATA_PROVIDER=excel # excel | sql
EXCEL_DIR=./services/data/data_files
CACHE_DIR=./services/data/.cache
INDEXED_KEYS=name,id,symbol
HEADER_SCAN_ROWS=15
QUERY_CACHE_SIZE=512
QUERY_CACHE_TTL=300
SQL_URL=sqlite:///./.cache/astrololo.db
# --- warstwa logiczna ---
DATA_URL=http://localhost:8002
# --- warstwa prezentacji ---
LOGIC_URL=http://localhost:8001
+32
View File
@@ -0,0 +1,32 @@
name: build-swisseph
# Osobny pipeline dla silnika B (Swiss Ephemeris, AGPL) — celowo ODDZIELONY od
# głównego build.yaml (data/logic/presentation). Buduje się tylko, gdy zmienia się
# sam silnik, i nie miesza obrazu AGPL do pipeline'u permisywnego produktu.
#
# Obraz konsumuje profil deployu `astrololo-swisseph` w repo `deploy`.
on:
push:
branches: [master]
paths:
- 'services/engine-swisseph/**'
- '.gitea/workflows/build-swisseph.yaml'
workflow_dispatch: {} # ręczne odpalenie (bootstrap pierwszego obrazu)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push engine-swisseph (AGPL, izolowany)
run: |
TAG=${GITHUB_SHA::8}
IMG=gitea.czernobog.pl/gitea/astrololo-engine-swisseph
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312) — build jest
# zarazem realnym testem Dockerfile'a.
docker build -t $IMG:$TAG -t $IMG:latest ./services/engine-swisseph
docker push $IMG:$TAG
docker push $IMG:latest
echo "Zbudowano i wypchnięto: $IMG:$TAG (+ latest)"
+19
View File
@@ -0,0 +1,19 @@
name: build
on:
push:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push (data, logic, presentation)
run: |
TAG=${GITHUB_SHA::8}
for SVC in data logic presentation; do
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
done
echo "Tag: $TAG"
+142
View File
@@ -0,0 +1,142 @@
name: Testy
# Odpala się przy każdym pushu (dowolna gałąź) oraz dla pull requestów do master.
on:
push:
pull_request:
branches: [master]
jobs:
logic-tests:
name: Testy warstwy logicznej (silnik)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python 3.12 (jak w obrazach Dockera)
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/logic/requirements-dev.txt
# Jądro efemeryd JPL (de421.bsp, ~17 MB). Cache'ujemy je między runami.
- name: Cache jądra efemeryd
uses: actions/cache@v4
with:
path: services/logic/.ephemeris
key: ephemeris-de421
# Pobieramy jawnie (a nie licząc na auto-pobranie przez Skyfield), żeby
# brak jądra był twardym błędem, a nie cichym pomijaniem testów.
- name: Pobierz jądro efemeryd (gdy brak w cache)
run: |
mkdir -p services/logic/.ephemeris
if [ ! -s services/logic/.ephemeris/de421.bsp ]; then
curl -fSL --retry 3 --max-time 300 \
-o services/logic/.ephemeris/de421.bsp \
https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp
fi
ls -lh services/logic/.ephemeris/de421.bsp
- name: Instalacja zależności
run: pip install -r services/logic/requirements-dev.txt
# CI=true (ustawiane przez GitHub) sprawia, że brak silnika = błąd,
# a nie pominięcie — patrz tests/conftest.py.
- name: Testy (pytest)
working-directory: services/logic
env:
PYTHONPATH: .
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
run: pytest tests -q -rs
presentation-tests:
name: Testy warstwy prezentacji (dostęp do baz)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/presentation/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/presentation/requirements-dev.txt
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
# niż jego brak, bo daje złudzenie zabezpieczenia.
- name: Testy (pytest)
working-directory: services/presentation
env:
PYTHONPATH: .
run: pytest tests -q -rs
swisseph-image:
name: Build obrazu silnika B (swisseph)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312), więc ten
# build jest realnym testem Dockerfile'a — nie tylko pobraniem paczek.
- name: docker build
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
# Test biegnie WEWNĄTRZ obrazu, bez sieci i bez kontenera w tle. Poprzednia
# wersja startowała kontener w tle (--name swe) i pukała curl-em w
# localhost:8003 — co miało dwie wady:
# 1. nie sprzątała kontenera, więc każdy kolejny przebieg padał na
# konflikcie nazwy (Conflict. The container name "/swe" is already in use),
# 2. job Gitea Actions sam działa w kontenerze, a -p publikuje port na
# HOŚCIE — więc localhost joba to nie ten sam localhost.
# Wywołanie funkcji endpointów wprost omija oba problemy, a sprawdza to samo:
# obraz się zbudował, pyswisseph liczy, kontrakt /positions się zgadza.
# --rm gwarantuje, że nic nie zostaje po przebiegu.
- name: Smoke test (health + pozycje) wewnątrz obrazu
run: |
docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
from datetime import datetime, timezone
from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
h = health()
assert h["status"] == "ok", h
print("health:", h)
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
lat=50.0647, lon=19.9450)
out = positions(req)
by = {p["name"]: p for p in out["positions"]}
assert out["engine"] == "swisseph", out["engine"]
assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
sun = by["Sun"]["longitude"]
assert 39.5 < sun < 41.0, f"Slonce poza oczekiwanym zakresem: {sun}"
nn, sn = by["North Node"]["longitude"], by["South Node"]["longitude"]
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
print("SMOKE OK")
PY
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
- name: Usuń osierocony kontener ze starych przebiegów
if: always()
run: docker rm -f swe 2>/dev/null || true
compile-all:
name: Kontrola składni wszystkich warstw
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# Sam kompilator — bez instalowania zależności warstw (w tym AGPL-owego
# silnika swisseph, który nie wchodzi do produktu).
- name: py_compile
run: python -m compileall -q services
+25
View File
@@ -0,0 +1,25 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# Cache warstwy bazodanowej (regenerowalny)
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
# Narzędzia
.env
.DS_Store
.idea/
.vscode/
+80
View File
@@ -0,0 +1,80 @@
.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 "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"
# --- 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 && $(PY) scripts/make_sample_data.py
reindex:
cd services/data && $(PY) -m app.ingest.build_index
migrate:
cd services/data && $(PY) -m app.ingest.to_sql
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:
cd services/data && uvicorn app.main:app --reload --port 8002
dev-logic:
cd services/logic && DATA_URL=http://localhost:8002 uvicorn app.main:app --reload --port 8001
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
+76 -1
View File
@@ -1 +1,76 @@
# astrololo
# astrololo
Aplikacja w **modelu trójwarstwowym**, w pełni modułowa: trzy niezależne usługi,
każda komunikuje się wyłącznie z sąsiadem (nigdy „przez głowę”).
```
┌──────────────────────┐ formularz (w dół) ┌──────────────────────┐ zapytanie (w dół) ┌──────────────────────┐
│ PREZENTACJA (:8000) │ ───────────────────▶ │ LOGICZNA (:8001) │ ───────────────────▶ │ BAZODANOWA (:8002) │
│ strona WWW + form │ ◀─────────────────── │ reguły biznesowe │ ◀─────────────────── │ wyszukiwanie danych │
└──────────────────────┘ wyniki (w górę) └──────────────────────┘ dane (w górę) └──────────────────────┘
HTML/UI pośrednik + logika Excel(+cache) ▸ SQL
```
Każda warstwa to osobny katalog, osobny `requirements.txt`, osobny `Dockerfile`
i osobne README. Komunikacja przez HTTP/JSON. Warstwa zna **tylko adres warstwy
bezpośrednio pod nią** — nic o jej wnętrzu.
| Warstwa | Katalog | Zna w dół | Zadanie |
|--------|---------|-----------|---------|
| Prezentacji | [`services/presentation`](services/presentation) | `LOGIC_URL` | serwuje stronę, przekazuje formularz, renderuje wyniki |
| Logiczna | [`services/logic`](services/logic) | `DATA_URL` | reguły biznesowe, tłumaczenie zapytań, opracowanie wyników |
| Bazodanowa | [`services/data`](services/data) | pliki Excela / SQL | **tylko** wyszukiwanie danych i podanie ich w górę |
## Szybki start (Docker)
```bash
make sample # przykładowe pliki .xlsx do warstwy bazodanowej
make up # zbuduj i uruchom 3 warstwy
# otwórz http://localhost:8000
```
## 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
make dev-data # terminal 1 -> :8002
make dev-logic # terminal 2 -> :8001
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.
- Wymień bazę (Excel → SQL) → prezentacja i logika bez zmian (patrz niżej).
- Każdą warstwę da się uruchomić, testować i wdrażać osobno.
## Wydajność warstwy Excela — cache 4-poziomowy
Dziś dane to setki dużych `.xlsx`, przeszukiwanych po **wykrytym nagłówku** i
**układzie kolumn**. To kosztowne, więc warstwa bazodanowa ma cache (szczegóły:
[`services/data/README.md`](services/data/README.md)):
1. **Schemat (L1, SQLite)** — wykryty nagłówek + mapowanie kolumn zapisane raz na wersję pliku.
2. **Dane (L2, Parquet)** — znormalizowany arkusz; kolejne odczyty 10100× szybsze niż `.xlsx`.
3. **Zapytania (L3, in-memory TTL/LRU)** — powtarzalne wyszukiwania natychmiast (łatwo podmienić na Redis).
4. **Odwrócony indeks (L4, SQLite)**`wartość → plik`; otwieramy tylko trafione pliki zamiast skanu setek.
Unieważnianie automatyczne: klucz cache = **odcisk pliku** (`mtime+rozmiar`,
opcjonalnie `sha256`). Zmiana pliku → przebudowa tylko jego wpisów.
## Droga na przyszłość — migracja do SQL
Warstwa bazodanowa ukrywa źródło za interfejsem `DataProvider` (wzorzec
Repository). Migracja:
```bash
make migrate # ETL: tym samym loaderem Excel -> tabela 'records' + indeksy
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
+46
View File
@@ -0,0 +1,46 @@
services:
data:
build: ./services/data
environment:
DATA_PROVIDER: ${DATA_PROVIDER:-excel}
EXCEL_DIR: /app/data_files
CACHE_DIR: /app/.cache
INDEXED_KEYS: name,id,symbol
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
ports:
- "8002:8002"
logic:
build: ./services/logic
environment:
DATA_URL: http://data:8002
EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny
# adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison)
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on:
- data
ports:
- "8001:8001"
# 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:
LOGIC_URL: http://logic:8001
depends_on:
- logic
ports:
- "8000:8000"
volumes:
data_cache:
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8002
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]
+46
View File
@@ -0,0 +1,46 @@
# Warstwa bazodanowa (`data`)
Niezależna usługa. **Jedyne zadanie:** wyszukać dane i podać je w górę. Nie zna
warstwy logicznej ani prezentacji — komunikacja wyłącznie przez HTTP/JSON
(`models.py`).
## API
- `POST /search``SearchQuery``SearchResult`
- `GET /health``HealthInfo`
## Architektura wewnętrzna
```
providers/ wymienna implementacja (wzorzec Repository)
base.py interfejs DataProvider ← kontrakt
excel_provider dziś: Excel + 4 poziomy cache
sql_provider jutro: SQL (ten sam interfejs)
factory.py DATA_PROVIDER=excel|sql
excel/ wykrywanie nagłówka + mapowanie układu kolumn (jedyne miejsce znające .xlsx)
cache/ fingerprint, schema(L1), frame/parquet(L2), query(L3), index(L4)
ingest/ build_index.py (warmup), to_sql.py (migracja ETL)
```
## Cache — dlaczego szybko
| Poziom | Co cache'uje | Zysk |
|-------|---------------|------|
| L1 `schema.db` | wykryty nagłówek + układ kolumn per plik | brak ponownego skanu heurystyką |
| L2 Parquet | znormalizowany arkusz | 10100× szybciej niż parsowanie `.xlsx` |
| L3 `QueryCache` | wynik zapytania (TTL/LRU) | powtarzalne zapytania natychmiast |
| L4 `index.db` | odwrócony indeks wartość→plik | otwieramy tylko trafione pliki, nie setki |
Unieważnianie: klucz = odcisk pliku (`mtime+rozmiar`, opcjonalnie `sha256`).
Zmiana pliku → inny odcisk → automatyczny przebudowa.
## Uruchomienie lokalne
```bash
pip install -r requirements.txt
python scripts/make_sample_data.py # przykładowe .xlsx
python -m app.ingest.build_index # (opcjonalnie) prebuild indeksu
uvicorn app.main:app --port 8002
```
## Migracja do SQL (gdy nadejdzie czas)
```bash
python -m app.ingest.to_sql # Excel -> tabela 'records' + indeksy
export DATA_PROVIDER=sql # przełącz warstwę — reszta systemu bez zmian
```
View File
View File
+22
View File
@@ -0,0 +1,22 @@
"""Odcisk pliku = klucz unieważniania cache.
Wszystkie warstwy cache są kluczowane odciskiem pliku. Gdy plik Excela się zmieni,
zmienia się odcisk -> automatyczny "cache miss" i przebudowa. Domyślnie używamy
taniego (mtime + rozmiar); sha256 dostępne, gdy potrzeba pewności co do treści.
"""
from __future__ import annotations
import hashlib
import os
def fingerprint(path: str, strong: bool = False) -> str:
if strong:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return "sha256:" + h.hexdigest()[:16]
st = os.stat(path)
return f"mt:{int(st.st_mtime)}:{st.st_size}"
+40
View File
@@ -0,0 +1,40 @@
"""Cache danych: znormalizowany arkusz zapisany jako Parquet.
POZIOM 2 cache — największy zysk wydajności. Parsowanie .xlsx jest wolne
(dziesiątkisetki ms na duży plik). Po pierwszym wczytaniu zapisujemy
znormalizowaną ramkę jako Parquet (kolumnowy, kompresowany), kluczowaną odciskiem
pliku. Kolejne odczyty ładują Parquet — zwykle 10100x szybciej niż .xlsx i bez
ponownego wykrywania nagłówka.
Plik Parquet jest też naturalnym formatem pośrednim przy migracji do SQL.
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
class FrameCache:
def __init__(self, cache_dir: Path) -> None:
self.dir = cache_dir / "frames"
self.dir.mkdir(parents=True, exist_ok=True)
def _path(self, fp: str, sheet: str) -> Path:
safe = fp.replace(":", "_") + "__" + str(sheet).replace("/", "_")
return self.dir / f"{safe}.parquet"
def get(self, fp: str, sheet: str) -> pd.DataFrame | None:
p = self._path(fp, sheet)
if p.exists():
return pd.read_parquet(p)
return None
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
# 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)
+68
View File
@@ -0,0 +1,68 @@
"""Odwrócony indeks: wartość kanonicznego klucza -> które pliki/arkusze ją mają.
POZIOM 4 (i najważniejszy przy skali) — pozwala NIE skanować setek plików przy
każdym zapytaniu. Budujemy w SQLite indeks: dla wybranych kluczy (np. name, id,
symbol) zapisujemy, w którym pliku/arkuszu występuje dana wartość. Wyszukiwanie
najpierw pyta indeks (jeden szybki SELECT), a otwiera tylko trafione pliki.
Ten SQLite indeks jest jednocześnie POMOSTEM do pełnej migracji SQL — rozbudowa
go o wszystkie kolumny = de facto baza danych (patrz ingest/to_sql.py).
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
class InvertedIndex:
def __init__(self, cache_dir: Path) -> None:
self._db = sqlite3.connect(str(cache_dir / "index.db"), check_same_thread=False)
self._db.execute(
"""
CREATE TABLE IF NOT EXISTS entries (
key TEXT, -- kanoniczne pole, np. 'name'
value TEXT, -- znormalizowana (lower) wartość
file TEXT, -- ścieżka pliku
sheet TEXT
)
"""
)
self._db.execute("CREATE INDEX IF NOT EXISTS ix_kv ON entries(key, value)")
self._db.execute(
"CREATE TABLE IF NOT EXISTS files (file TEXT PRIMARY KEY, fingerprint TEXT)"
)
self._db.commit()
def file_fingerprint(self, file: str) -> str | None:
cur = self._db.execute("SELECT fingerprint FROM files WHERE file=?", (file,))
row = cur.fetchone()
return row[0] if row else None
def reindex_file(self, file: str, fingerprint: str, rows: list[tuple[str, str, str]]) -> None:
"""rows: lista (key, value, sheet) dla jednego pliku."""
self._db.execute("DELETE FROM entries WHERE file=?", (file,))
self._db.executemany(
"INSERT INTO entries(key, value, file, sheet) VALUES (?,?,?,?)",
[(k, v.lower(), file, sheet) for (k, v, sheet) in rows],
)
self._db.execute(
"INSERT OR REPLACE INTO files(file, fingerprint) VALUES (?,?)", (file, fingerprint)
)
self._db.commit()
def lookup(self, key: str, value: str, exact: bool) -> list[tuple[str, str]]:
"""Zwraca listę (file, sheet) kandydatów do przeszukania."""
if exact:
cur = self._db.execute(
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value=?",
(key, value.lower()),
)
else:
cur = self._db.execute(
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value LIKE ?",
(key, f"%{value.lower()}%"),
)
return [(r[0], r[1]) for r in cur.fetchall()]
def count_files(self) -> int:
return self._db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
+39
View File
@@ -0,0 +1,39 @@
"""Cache wyników zapytań: in-memory LRU + TTL.
POZIOM 3 cache. Te same zapytania powtarzają się (popularne wyszukiwania). Tu
trzymamy gotowy wynik przez krótki TTL. Bez zewnętrznych zależności — w produkcji
można podmienić na Redis (ten sam interfejs get/put), by współdzielić cache
między instancjami.
"""
from __future__ import annotations
import time
from collections import OrderedDict
from typing import Any
class QueryCache:
def __init__(self, max_size: int = 512, ttl: int = 300) -> None:
self.max_size = max_size
self.ttl = ttl
self._store: OrderedDict[str, tuple[float, Any]] = OrderedDict()
def get(self, key: str) -> Any | None:
item = self._store.get(key)
if item is None:
return None
ts, value = item
if time.time() - ts > self.ttl:
del self._store[key]
return None
self._store.move_to_end(key)
return value
def put(self, key: str, value: Any) -> None:
self._store[key] = (time.time(), value)
self._store.move_to_end(key)
while len(self._store) > self.max_size:
self._store.popitem(last=False)
def clear(self) -> None:
self._store.clear()
+45
View File
@@ -0,0 +1,45 @@
"""Cache schematu: wykryty wiersz nagłówka + mapowanie kolumn, per (plik, arkusz).
POZIOM 1 cache. Najdroższe jest samo wykrywanie nagłówka i wnioskowanie układu
kolumn. Robimy to RAZ na wersję pliku i zapisujemy w SQLite. Kolejne odczyty
pomijają skanowanie.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
class SchemaCache:
def __init__(self, cache_dir: Path) -> None:
self._db = sqlite3.connect(str(cache_dir / "schema.db"), check_same_thread=False)
self._db.execute(
"""
CREATE TABLE IF NOT EXISTS schema_cache (
fingerprint TEXT,
sheet TEXT,
header_row INTEGER,
mapping TEXT,
PRIMARY KEY (fingerprint, sheet)
)
"""
)
self._db.commit()
def get(self, fp: str, sheet: str) -> tuple[int, dict[str, str]] | None:
cur = self._db.execute(
"SELECT header_row, mapping FROM schema_cache WHERE fingerprint=? AND sheet=?",
(fp, sheet),
)
row = cur.fetchone()
if row is None:
return None
return int(row[0]), json.loads(row[1])
def put(self, fp: str, sheet: str, header_row: int, mapping: dict[str, str]) -> None:
self._db.execute(
"INSERT OR REPLACE INTO schema_cache VALUES (?,?,?,?)",
(fp, sheet, header_row, json.dumps(mapping, ensure_ascii=False)),
)
self._db.commit()
+49
View File
@@ -0,0 +1,49 @@
"""Konfiguracja warstwy bazodanowej (z ENV).
Najważniejsza zmienna: DATA_PROVIDER. Zmiana 'excel' -> 'sql' przełącza całą
warstwę na bazę SQL bez dotykania pozostałych modułów (patrz providers/factory.py).
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent # .../services/data
@dataclass
class Settings:
# 'excel' (dziś) albo 'sql' (po migracji)
provider: str = field(default_factory=lambda: os.getenv("DATA_PROVIDER", "excel"))
# Warstwa Excel
excel_dir: Path = field(
default_factory=lambda: Path(os.getenv("EXCEL_DIR", str(BASE_DIR / "data_files")))
)
cache_dir: Path = field(
default_factory=lambda: Path(os.getenv("CACHE_DIR", str(BASE_DIR / ".cache")))
)
header_scan_rows: int = field(default_factory=lambda: int(os.getenv("HEADER_SCAN_ROWS", "15")))
# Klucze kanoniczne, które trafiają do odwróconego indeksu (przyspiesza wyszukiwanie).
indexed_keys: tuple[str, ...] = field(
default_factory=lambda: tuple(
k.strip() for k in os.getenv("INDEXED_KEYS", "name,id,symbol").split(",") if k.strip()
)
)
# Cache zapytań (in-memory)
query_cache_size: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_SIZE", "512")))
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
# Warstwa SQL (po migracji)
sql_url: str = field(
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
)
def __post_init__(self) -> None:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.excel_dir.mkdir(parents=True, exist_ok=True)
settings = Settings()
View File
+52
View File
@@ -0,0 +1,52 @@
"""Wykrywanie wiersza nagłówka w arkuszu.
Pliki Excela w tej domenie nie mają nagłówka zawsze w pierwszym wierszu — bywają
puste wiersze, tytuły, metadane. Ta heurystyka skanuje pierwsze N wierszy i
wybiera ten, który "wygląda jak nagłówek": dużo niepustych, tekstowych,
unikalnych komórek, po którym następują wiersze danych o podobnym wypełnieniu.
Wynik (indeks wiersza nagłówka) jest CACHE'OWANY per plik (schema_cache), więc
ten kosztowny skan robimy raz na wersję pliku.
"""
from __future__ import annotations
import pandas as pd
def _row_score(raw: pd.DataFrame, i: int) -> float:
row = raw.iloc[i]
non_null = row.notna()
filled = float(non_null.mean()) if len(row) else 0.0
if filled == 0:
return -1.0
values = [str(v).strip() for v in row[non_null].tolist()]
text_like = sum(1 for v in values if v and not _looks_numeric(v))
text_ratio = text_like / max(len(values), 1)
uniqueness = len(set(values)) / max(len(values), 1)
# Wiersze danych pod spodem powinny mieć podobną liczbę wypełnionych kolumn.
follow_bonus = 0.0
if i + 1 < len(raw):
below = raw.iloc[i + 1].notna().mean()
follow_bonus = 1.0 - abs(filled - float(below))
return filled * 0.4 + text_ratio * 0.3 + uniqueness * 0.2 + follow_bonus * 0.1
def _looks_numeric(v: str) -> bool:
try:
float(v.replace(",", "."))
return True
except ValueError:
return False
def detect_header_row(raw: pd.DataFrame, max_scan: int = 15) -> int:
"""Zwraca indeks (0-based) wiersza, który najprawdopodobniej jest nagłówkiem."""
best_idx, best_score = 0, float("-inf")
for i in range(min(max_scan, len(raw))):
score = _row_score(raw, i)
if score > best_score:
best_idx, best_score = i, score
return best_idx
+52
View File
@@ -0,0 +1,52 @@
"""Mapowanie układu kolumn na schemat kanoniczny.
Setki plików mogą mieć te same dane pod różnymi nagłówkami i w różnej kolejności
kolumn ("Imię", "Name", "NAZWA" -> kanoniczne 'name'). Ta warstwa tłumaczy
faktyczny układ kolumn pliku na wspólny słownik pól, dzięki czemu reszta systemu
(i przyszła baza SQL) operuje na jednej, stabilnej nazwie pola.
Aliasowanie jest świadomie wydzielone i konfigurowalne — to jedyne miejsce do
edycji, gdy pojawi się nowy wariant nagłówka.
"""
from __future__ import annotations
import re
# kanoniczne_pole -> zbiór aliasów (po normalizacji)
CANONICAL_ALIASES: dict[str, set[str]] = {
"id": {"id", "identyfikator", "nr", "no", "number"},
"name": {"name", "imie", "nazwa", "nazwisko", "title", "tytul"},
"symbol": {"symbol", "znak", "sign", "glyph"},
"date": {"date", "data", "datetime", "timestamp"},
"value": {"value", "wartosc", "val", "amount", "kwota"},
"category": {"category", "kategoria", "type", "typ", "group", "grupa"},
}
def _normalize(col: str) -> str:
s = str(col).strip().lower()
s = re.sub(r"[ąàá]", "a", s)
s = s.replace("ł", "l").replace("ż", "z").replace("ź", "z").replace("ć", "c")
s = s.replace("ę", "e").replace("ó", "o").replace("ś", "s").replace("ń", "n")
s = re.sub(r"[^a-z0-9]+", "", s)
return s
def build_column_mapping(header_cells: list[str]) -> dict[str, str]:
"""Zwraca mapę pole_kanoniczne -> faktyczna_nazwa_kolumny dla danego pliku.
Kolumny nierozpoznane są zachowywane pod swoją (znormalizowaną) nazwą, więc
nic nie ginie — po prostu nie mają aliasu kanonicznego.
"""
reverse: dict[str, str] = {}
for canonical, aliases in CANONICAL_ALIASES.items():
for alias in aliases:
reverse[alias] = canonical
mapping: dict[str, str] = {}
for actual in header_cells:
norm = _normalize(actual)
canonical = reverse.get(norm, norm or "col")
# pierwsze trafienie wygrywa (stabilność przy duplikatach)
mapping.setdefault(canonical, actual)
return mapping
+43
View File
@@ -0,0 +1,43 @@
"""Wczytanie pojedynczego arkusza do znormalizowanej ramki danych.
Łączy wykrywanie nagłówka (header_detect) z mapowaniem układu kolumn (layout).
Zwraca ramkę o KANONICZNYCH nazwach kolumn — gotową do indeksowania, cache'owania
(parquet) i ewentualnego załadowania do SQL.
To jest jedyne miejsce, które "rozumie" format Excela. Reszta systemu jej nie
widzi.
"""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
from app.excel.header_detect import detect_header_row
from app.excel.layout import build_column_mapping
@dataclass
class LoadedSheet:
frame: pd.DataFrame # dane z kanonicznymi kolumnami
header_row: int # wykryty indeks nagłówka
column_mapping: dict[str, str] # pole_kanoniczne -> oryginalna_nazwa
def load_sheet(path: str, sheet: str | int = 0, header_scan_rows: int = 15) -> LoadedSheet:
raw = pd.read_excel(path, sheet_name=sheet, header=None, dtype=object)
header_row = detect_header_row(raw, max_scan=header_scan_rows)
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
mapping = build_column_mapping(header_cells)
data = raw.iloc[header_row + 1 :].copy()
data.columns = header_cells
data = data.dropna(how="all")
# przenazwij na kanoniczne pola: {oryginał -> kanoniczne}
inverse = {orig: canon for canon, orig in mapping.items()}
data = data.rename(columns=inverse)
data = data.reset_index(drop=True)
return LoadedSheet(frame=data, header_row=header_row, column_mapping=mapping)
+25
View File
@@ -0,0 +1,25 @@
"""Wstępne zbudowanie cache + odwróconego indeksu dla wszystkich plików Excela.
Uruchom raz po wgraniu/aktualizacji plików (albo zostaw warmup przy starcie usługi):
python -m app.ingest.build_index
"""
from __future__ import annotations
from app.config import settings
from app.providers.excel_provider import ExcelDataProvider
def main() -> None:
provider = ExcelDataProvider(settings)
files = provider._excel_files()
print(f"Indeksuję {len(files)} plików z {settings.excel_dir} ...")
for i, path in enumerate(files, 1):
provider._ensure_indexed(path)
if i % 25 == 0 or i == len(files):
print(f" {i}/{len(files)}")
print(f"Gotowe. Zaindeksowane pliki: {provider.index.count_files()}")
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
"""ETL migracji: setki plików Excela -> jedna zoptymalizowana tabela SQL.
To jest "łatwa droga na przyszłość". Skrypt używa DOKŁADNIE tego samego loadera
co warstwa Excela (wykrywanie nagłówka + mapowanie kolumn kanonicznych), więc
dane trafiają do SQL już znormalizowane i spójne. Po załadowaniu wystarczy
ustawić DATA_PROVIDER=sql.
python -m app.ingest.to_sql
Kroki:
1. wczytaj każdy plik loaderem -> ramka o kanonicznych kolumnach,
2. dołóż kolumnę źródła (_source_file) dla audytu,
3. dopisz do tabeli 'records',
4. załóż indeksy na kluczach kanonicznych (przyspieszenie zapytań).
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from sqlalchemy import create_engine, text
from app.config import settings
from app.excel.loader import load_sheet
def main() -> None:
engine = create_engine(settings.sql_url, future=True)
files = sorted(Path(settings.excel_dir).glob("**/*.xlsx"))
print(f"Migruję {len(files)} plików -> {settings.sql_url}")
first = True
for path in files:
if path.name.startswith("~$"):
continue
loaded = load_sheet(str(path), header_scan_rows=settings.header_scan_rows)
frame = loaded.frame.copy()
frame["_source_file"] = path.name
frame.to_sql("records", engine, if_exists="replace" if first else "append", index=False)
first = False
with engine.connect() as conn:
for key in settings.indexed_keys:
try:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_records_{key} ON records({key})"))
except Exception as e: # kolumna może nie istnieć w tym zbiorze
print(f" (pomijam indeks {key}: {e})")
conn.commit()
print("Migracja zakończona. Ustaw DATA_PROVIDER=sql aby przełączyć warstwę.")
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
"""Warstwa BAZODANOWA — usługa HTTP.
Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszukać
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
ani prezentacji.
"""
from __future__ import annotations
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
provider = build_provider(settings)
@asynccontextmanager
async def lifespan(app: FastAPI):
provider.warmup() # zbuduj/odśwież indeks i cache przy starcie
yield
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
security.install(app, "danych") # token międzywarstwowy (LOG-32)
@app.post("/search", response_model=SearchResult)
def search(query: SearchQuery) -> SearchResult:
return provider.search(query)
@app.get("/health", response_model=HealthInfo)
def health() -> HealthInfo:
return provider.health()
+44
View File
@@ -0,0 +1,44 @@
"""Kontrakt danych warstwy bazodanowej.
Te modele są JEDYNYM publicznym interfejsem tej warstwy. Warstwa logiczna zna
wyłącznie te kształty (poprzez HTTP/JSON) — nie wie nic o Excelu, cache ani SQL.
Dzięki temu można podmienić implementację (Excel -> SQL) bez zmiany pozostałych
warstw.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class SearchQuery(BaseModel):
"""Znormalizowane zapytanie wyszukiwania przychodzące z warstwy logicznej."""
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).")
# 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."
)
class SearchResult(BaseModel):
"""Wynik wyszukiwania zwracany w górę do warstwy logicznej."""
rows: list[dict[str, Any]]
total: int
elapsed_ms: float
cache: str = Field("miss", description="hit/miss/partial — skąd pochodzą dane.")
provider: str = Field(..., description="Nazwa aktywnej implementacji, np. 'excel' lub 'sql'.")
class HealthInfo(BaseModel):
status: str = "ok"
provider: str
indexed_files: int = 0
details: dict[str, Any] = Field(default_factory=dict)
+27
View File
@@ -0,0 +1,27 @@
"""Abstrakcyjny interfejs dostawcy danych (wzorzec Repository/Strategy).
To jest klucz do "łatwej migracji do SQL". Warstwa bazodanowa udostępnia na
zewnątrz tylko ten kontrakt. Dziś realizuje go ExcelDataProvider, jutro
SqlDataProvider — bez żadnej zmiany w warstwie logicznej i prezentacji.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from app.models import HealthInfo, SearchQuery, SearchResult
class DataProvider(ABC):
name: str = "base"
@abstractmethod
def search(self, query: SearchQuery) -> SearchResult:
"""Wyszuka dane i zwróci je w górę. JEDYNE zadanie tej warstwy."""
@abstractmethod
def health(self) -> HealthInfo:
...
def warmup(self) -> None:
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
return None
@@ -0,0 +1,148 @@
"""ExcelDataProvider — wyszukiwanie w setkach plików .xlsx z 4-poziomowym cache.
Ścieżka zapytania (od najszybszej):
1) QueryCache (in-memory) -> gotowy wynik
2) InvertedIndex (SQLite) -> które pliki w ogóle otwierać (zamiast skanu setek)
3) FrameCache (Parquet) -> wczytanie pliku bez parsowania .xlsx
4) SchemaCache (SQLite) -> bez ponownego wykrywania nagłówka/układu kolumn
...dopiero gdy wszystko spudłuje, czytamy .xlsx i wypełniamy cache.
Cała ta złożoność jest UKRYTA za interfejsem DataProvider.
"""
from __future__ import annotations
import time
from pathlib import Path
import pandas as pd
from app.cache.fingerprint import fingerprint
from app.cache.frame_cache import FrameCache
from app.cache.index import InvertedIndex
from app.cache.query_cache import QueryCache
from app.cache.schema_cache import SchemaCache
from app.config import Settings
from app.excel.header_detect import detect_header_row
from app.excel.layout import build_column_mapping
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.base import DataProvider
class ExcelDataProvider(DataProvider):
name = "excel"
def __init__(self, settings: Settings) -> None:
self.s = settings
self.schema = SchemaCache(settings.cache_dir)
self.frames = FrameCache(settings.cache_dir)
self.index = InvertedIndex(settings.cache_dir)
self.queries = QueryCache(settings.query_cache_size, settings.query_cache_ttl)
# ---- ładowanie pojedynczego arkusza z pełnym cache ----
def _load_frame(self, path: str, sheet: str | int = 0) -> pd.DataFrame:
fp = fingerprint(path)
sheet_key = str(sheet)
cached = self.frames.get(fp, sheet_key) # poziom 2: Parquet
if cached is not None:
return cached
raw = pd.read_excel(path, sheet_name=sheet, header=None, dtype=object)
meta = self.schema.get(fp, sheet_key) # poziom 1: schemat
if meta is None:
header_row = detect_header_row(raw, self.s.header_scan_rows)
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
mapping = build_column_mapping(header_cells)
self.schema.put(fp, sheet_key, header_row, mapping)
else:
header_row, mapping = meta
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
data = raw.iloc[header_row + 1 :].copy()
data.columns = header_cells
data = data.dropna(how="all")
inverse = {orig: canon for canon, orig in mapping.items()}
data = data.rename(columns=inverse).reset_index(drop=True)
self.frames.put(fp, sheet_key, data) # zapisz Parquet na przyszłość
return data
# ---- budowa odwróconego indeksu (warmup / po zmianie pliku) ----
def _ensure_indexed(self, path: str) -> None:
fp = fingerprint(path)
if self.index.file_fingerprint(path) == fp:
return # aktualny
frame = self._load_frame(path)
rows: list[tuple[str, str, str]] = []
for key in self.s.indexed_keys:
if key in frame.columns:
for v in frame[key].dropna().astype(str).unique():
rows.append((key, v, "0"))
self.index.reindex_file(path, fp, rows)
def warmup(self) -> None:
for path in self._excel_files():
try:
self._ensure_indexed(path)
except Exception as e: # jeden uszkodzony plik nie może zablokować startu
print(f"[data] pominięto plik przy indeksowaniu: {path}{e}")
def _excel_files(self) -> list[str]:
base = Path(self.s.excel_dir)
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
# ---- publiczne API ----
def search(self, query: SearchQuery) -> SearchResult:
t0 = time.perf_counter()
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
if hit is not None:
hit = hit.model_copy(update={"cache": "hit", "elapsed_ms": _ms(t0)})
return hit
candidates = self.index.lookup(query.key, query.value, query.exact)
if not candidates:
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
candidates = [(p, "0") for p in self._excel_files()]
rows: list[dict] = []
for path, _sheet in candidates:
frame = self._load_frame(path)
if query.key not in frame.columns:
continue
col = frame[query.key].astype(str)
if query.exact:
mask = col.str.lower() == query.value.lower()
else:
# 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]
matched = matched[keep]
rows.extend(matched.to_dict(orient="records"))
if len(rows) >= query.limit:
break
result = SearchResult(
rows=rows[: query.limit],
total=len(rows),
elapsed_ms=_ms(t0),
cache="miss",
provider=self.name,
)
self.queries.put(cache_key, result)
return result
def health(self) -> HealthInfo:
return HealthInfo(
provider=self.name,
indexed_files=self.index.count_files(),
details={"excel_dir": str(self.s.excel_dir), "files_on_disk": len(self._excel_files())},
)
def _ms(t0: float) -> float:
return round((time.perf_counter() - t0) * 1000, 2)
+19
View File
@@ -0,0 +1,19 @@
"""Fabryka dostawcy danych — jedyne miejsce, które wie o konkretnych implementacjach.
Przełączenie Excel <-> SQL: ustaw DATA_PROVIDER w środowisku. Nic poza tym.
"""
from __future__ import annotations
from app.config import Settings
from app.providers.base import DataProvider
def build_provider(settings: Settings) -> DataProvider:
if settings.provider == "sql":
from app.providers.sql_provider import SqlDataProvider
return SqlDataProvider(settings)
from app.providers.excel_provider import ExcelDataProvider
return ExcelDataProvider(settings)
@@ -0,0 +1,47 @@
"""SqlDataProvider — implementacja docelowa (po migracji z Excela).
Szkielet. Realizuje TEN SAM interfejs DataProvider, więc przełączenie to tylko
zmiana zmiennej środowiskowej DATA_PROVIDER=sql (patrz factory.py). Warstwa
logiczna i prezentacji nie zmieniają ani jednej linii.
Dane ładuje do bazy skrypt ingest/to_sql.py (ten sam loader Excela -> tabele SQL).
"""
from __future__ import annotations
import time
from sqlalchemy import create_engine, text
from app.config import Settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.base import DataProvider
class SqlDataProvider(DataProvider):
name = "sql"
def __init__(self, settings: Settings) -> None:
self.s = settings
self.engine = create_engine(settings.sql_url, future=True)
def search(self, query: SearchQuery) -> SearchResult:
t0 = time.perf_counter()
op = "=" if query.exact else "LIKE"
val = query.value if query.exact else f"%{query.value}%"
cols = ", ".join(query.fields) if query.fields else "*"
# UWAGA: nazwy kolumn/tabel walidować względem białej listy schematu.
sql = text(f"SELECT {cols} FROM records WHERE {query.key} {op} :v LIMIT :lim")
with self.engine.connect() as conn:
rows = [dict(r._mapping) for r in conn.execute(sql, {"v": val, "lim": query.limit})]
return SearchResult(
rows=rows,
total=len(rows),
elapsed_ms=round((time.perf_counter() - t0) * 1000, 2),
cache="miss",
provider=self.name,
)
def health(self) -> HealthInfo:
with self.engine.connect() as conn:
n = conn.execute(text("SELECT COUNT(*) FROM records")).scalar() or 0
return HealthInfo(provider=self.name, indexed_files=0, details={"records": int(n)})
+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)
View File
+9
View File
@@ -0,0 +1,9 @@
# Dolne ograniczenia (>=) — działa zarówno na Pythonie 3.12 (obraz Docker),
# jak i na najnowszym 3.14 lokalnie. Przypnij dokładne wersje, gdy ustabilizujesz środowisko.
fastapi>=0.115
uvicorn[standard]>=0.34
pandas>=2.2
openpyxl>=3.1
pyarrow>=18.0
SQLAlchemy>=2.0
pydantic>=2.10
+43
View File
@@ -0,0 +1,43 @@
"""Generuje kilka przykładowych plików .xlsx do dema.
Celowo różnicuje: pozycję nagłówka (puste wiersze/tytuł nad nagłówkiem) oraz
kolejność i nazwy kolumn ("Imię"/"Name", "Symbol"/"Znak") — żeby pokazać działanie
wykrywania nagłówka i mapowania układu kolumn.
python scripts/make_sample_data.py
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
OUT = Path(__file__).resolve().parent.parent / "data_files"
OUT.mkdir(parents=True, exist_ok=True)
SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo"]
def file_a() -> None:
# nagłówek w 1. wierszu, nazwy PL
df = pd.DataFrame(
{"id": [1, 2, 3], "Imię": SIGNS[:3], "Symbol": ["", "", ""], "Wartość": [10, 20, 30]}
)
df.to_excel(OUT / "zodiac_pl.xlsx", index=False)
def file_b() -> None:
# tytuł + pusty wiersz nad nagłówkiem, nazwy EN, inna kolejność kolumn
with pd.ExcelWriter(OUT / "zodiac_en.xlsx") as xl:
meta = pd.DataFrame([["Tabela astrologiczna — wersja 2"], [None]])
meta.to_excel(xl, index=False, header=False, startrow=0)
df = pd.DataFrame(
{"Sign": ["", "", ""], "Name": SIGNS[3:], "No": [4, 5, 6], "Value": [40, 50, 60]}
)
df.to_excel(xl, index=False, startrow=2)
if __name__ == "__main__":
file_a()
file_b()
print(f"Zapisano przykładowe pliki w {OUT}")
+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
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8001
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
+47
View File
@@ -0,0 +1,47 @@
# Warstwa logiczna (`logic`)
Niezależna usługa pośrednicząca. **W górę** udostępnia API dla prezentacji,
**w dół** woła warstwę bazodanową. Tu żyją reguły biznesowe — nie w prezentacji
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.
## Uruchomienie
```bash
pip install -r requirements.txt
export DATA_URL=http://localhost:8002
uvicorn app.main:app --port 8001
```
## Gdzie rozbudowywać domenę
`service.py``QueryService.handle()`: walidacja wejścia, tłumaczenie zapytania,
obliczenia i wzbogacanie wyników.
View File
+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
+44
View File
@@ -0,0 +1,44 @@
"""Klient HTTP do warstwy bazodanowej.
Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
(Excel→SQL), tutaj nie zmienia się NIC — kontrakt /search jest stały.
"""
from __future__ import annotations
import os
from typing import Any
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,
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", headers=_auth_headers())
r.raise_for_status()
return r.json()
+18
View File
@@ -0,0 +1,18 @@
"""Konfiguracja warstwy logicznej.
Zna TYLKO adres warstwy bazodanowej (w dół). Nie wie nic o jej wnętrzu
(Excel/SQL/cache).
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass
class Settings:
data_url: str = field(default_factory=lambda: os.getenv("DATA_URL", "http://localhost:8002"))
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
settings = Settings()
+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."""
+75
View File
@@ -0,0 +1,75 @@
"""Katalog modeli do wyboru w UI (LOG-31).
To są **podpowiedzi**, nie zamknięta lista. Pole modelu w UI jest tekstowe z
datalistą, więc można wpisać dowolny identyfikator — konto może mieć dostęp do
modeli, których tu nie ma, a nowe wychodzą szybciej, niż aktualizuje się kod.
Puste pole = model domyślny dostawcy.
Uwaga o pewności danych:
* modele **Anthropic** pochodzą z oficjalnej dokumentacji API (okna kontekstu
i limity wyjścia zgadzają się z `app/llm/limits.py`);
* modele **OpenAI** to podpowiedzi — nie weryfikowałem ich katalogu, więc
traktuj je jako wygodę, a nie źródło prawdy;
* modele **lokalne** zależą wyłącznie od tego, co masz pobrane w Ollamie/vLLM.
Katalog można nadpisać/rozszerzyć zmienną `<DOSTAWCA>_MODELS` (lista po przecinku),
np. `OPENAI_MODELS="gpt-5,gpt-4o"`.
"""
from __future__ import annotations
import os
from app.llm.limits import limits_for
# dostawca -> [(id modelu, krótki opis dla człowieka)]
_CATALOG: dict[str, list[tuple[str, str]]] = {
"anthropic": [
("claude-opus-4-8", "Opus 4.8 — domyślny, bardzo zdolny, 1M kontekstu"),
("claude-fable-5", "Fable 5 — najbardziej zdolny, do najtrudniejszych zadań"),
("claude-sonnet-5", "Sonnet 5 — szybszy i tańszy, jakość blisko Opusa"),
("claude-opus-4-7", "Opus 4.7 — poprzednia generacja Opusa"),
("claude-haiku-4-5", "Haiku 4.5 — najszybszy i najtańszy, mniejsze okno"),
],
"openai": [
("gpt-4o-mini", "GPT-4o mini — tani i szybki"),
("gpt-4o", "GPT-4o"),
("gpt-5", "GPT-5 — jeśli Twoje konto ma dostęp"),
("gpt-4.1", "GPT-4.1"),
("gpt-4.1-mini", "GPT-4.1 mini"),
],
"local": [
("llama3.1:8b", "Llama 3.1 8B"),
("llama3.2", "Llama 3.2"),
("qwen2.5", "Qwen 2.5 — większe okno kontekstu"),
("mistral", "Mistral"),
],
}
def models_for(provider: str) -> list[dict]:
"""Podpowiedzi modeli dla dostawcy, wraz z oknem kontekstu.
Okno kontekstu podajemy, bo wprost przekłada się na opcję „maksymalny
kontekst modelu" — użytkownik widzi, na ile budżetu promptu może liczyć.
"""
override = os.getenv(f"{provider.upper()}_MODELS", "").strip()
if override:
entries = [(m.strip(), "") for m in override.split(",") if m.strip()]
else:
entries = _CATALOG.get(provider, [])
out = []
for model_id, label in entries:
context_window, max_output = limits_for(provider, model_id)
out.append({
"id": model_id,
"label": label or model_id,
"context_window": context_window,
"max_output": max_output,
})
return out
def catalog() -> dict[str, list[dict]]:
"""Pełny katalog dla UI — jedno żądanie zamiast trzech."""
return {provider: models_for(provider) for provider in ("local", "anthropic", "openai")}
+121
View File
@@ -0,0 +1,121 @@
"""Wybór dostawcy LLM (LOG-31) — jedyne miejsce znające konkretne implementacje.
Domyślny jest **model lokalny**: prompt niesie oryginalne opisy z baz, więc
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
przez konfigurację albo pojedyncze żądanie.
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
Zmienne środowiskowe:
LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
LLM_TIMEOUT sekundy (domyślnie 120)
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
<DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
LOCAL_MODEL, LOCAL_BASE_URL (klucz zwykle zbędny)
OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY
ANTHROPIC_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY
Klucze WYŁĄCZNIE z sekretu — nigdy w repo, w UI ani w logach.
Zgodność wstecz: wspólne `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` nadal działają,
ale stosują się TYLKO do dostawcy domyślnego (LLM_PROVIDER) — czyli konfiguracja
instalacji jednodostawcowej zostaje nietknięta, a pozostali dostawcy jej nie dziedziczą.
"""
from __future__ import annotations
import os
from app.llm.base import LLMError, LLMProvider
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
LOCAL = "local"
OPENAI = "openai"
ANTHROPIC = "anthropic"
PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
_DEFAULT_MODEL = {
LOCAL: "llama3.1:8b",
OPENAI: "gpt-4o-mini",
# Opus 4.8 świadomie zamiast Sonnet 5: Sonnet uruchamia myślenie adaptacyjne,
# gdy pominąć parametr `thinking`, a jego tokeny liczą się do max_tokens —
# przy ciasnym limicie cała tura wychodziła jako samo myślenie z pustym
# tekstem. To była przyczyna pustych odpowiedzi na Anthropicu.
ANTHROPIC: "claude-opus-4-8",
}
_DEFAULT_URL = {
# Ollama i vLLM wystawiają zgodne API pod /v1
LOCAL: "http://localhost:11434/v1",
OPENAI: "https://api.openai.com/v1",
ANTHROPIC: "https://api.anthropic.com",
}
def default_provider_name() -> str:
return os.getenv("LLM_PROVIDER", LOCAL).lower()
def max_tokens() -> int:
return int(os.getenv("LLM_MAX_TOKENS", "2000"))
def timeout() -> float:
return float(os.getenv("LLM_TIMEOUT", "120"))
def setting(provider: str, suffix: str, fallback: str = "") -> str:
"""Ustawienie dostawcy: <DOSTAWCA>_<SUFIKS> → LLM_<SUFIKS> → wbudowana domyślna.
Wspólne `LLM_*` stosuje się WYŁĄCZNIE do dostawcy domyślnego — inaczej adres
lokalnego modelu przejąłby żądania do chmury (i odwrotnie).
"""
specific = os.getenv(f"{provider.upper()}_{suffix}")
if specific:
return specific
if provider == default_provider_name():
generic = os.getenv(f"LLM_{suffix}")
if generic:
return generic
return fallback
def resolve_model(name: str | None = None, model: str | None = None) -> tuple[str, str]:
"""(dostawca, model) BEZ budowania dostawcy — czyli bez wymogu klucza API.
Rozmiar budżetu promptu zależy tylko od okna kontekstu modelu, więc nie może
zależeć od tego, czy klucz jest już skonfigurowany.
"""
provider = (name or default_provider_name()).lower()
if provider not in PROVIDERS:
provider = default_provider_name()
chosen = (model or "").strip() or setting(provider, "MODEL", _DEFAULT_MODEL[provider])
return provider, chosen
def build_provider(name: str | None = None, model: str | None = None) -> LLMProvider:
"""Dostawca modelu. `model` z żądania wygrywa nad konfiguracją — użytkownik
wybiera model w UI, a konfiguracja podaje tylko wartość domyślną."""
name = (name or default_provider_name()).lower()
if name not in PROVIDERS:
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
model = (model or "").strip() or setting(name, "MODEL", _DEFAULT_MODEL[name])
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
api_key = setting(name, "API_KEY")
if name in (OPENAI, ANTHROPIC) and not api_key:
raise LLMError(
f"Brak klucza dla dostawcy {name} — ustaw {name.upper()}_API_KEY "
f"(z sekretu). Model lokalny klucza nie wymaga."
)
if name == ANTHROPIC:
return AnthropicProvider(base_url, model, api_key, timeout())
if name == OPENAI:
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
leaves_lan=True)
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
return ChatCompletionsProvider(LOCAL, base_url, model, api_key, timeout(),
leaves_lan=False)
+133
View File
@@ -0,0 +1,133 @@
"""Okna kontekstu modeli i planowanie budżetu tokenów (LOG-30/31).
Po co to istnieje: horoskop MA powstać niezależnie od objętości promptu. Żeby to
zagwarantować, trzeba wiedzieć dwie rzeczy o każdym modelu — ile zmieści na
wejściu (okno kontekstu) i ile maksymalnie wypisze na wyjściu. Bez tego łatwo
wysłać prompt, który wypełnia całe okno i **nie zostawia miejsca na odpowiedź** —
model kończy wtedy na `max_tokens` z pustą albo uciętą treścią.
Zasada naczelna: **zawsze rezerwuj miejsce na odpowiedź.** Budżet promptu liczy
się jako `okno_kontekstu zarezerwowane_wyjście margines`, nigdy odwrotnie.
Wartości są zaszyte jako rozsądne domyślne i nadpisywalne środowiskiem
(`<DOSTAWCA>_CONTEXT_WINDOW`, `<DOSTAWCA>_MAX_OUTPUT`) — modele wychodzą szybciej,
niż aktualizuje się ten plik.
"""
from __future__ import annotations
import os
# model -> (okno kontekstu, maksymalne wyjście) w tokenach
_MODEL_LIMITS: dict[str, tuple[int, int]] = {
# Anthropic
"claude-opus-4-8": (1_000_000, 128_000),
"claude-opus-4-7": (1_000_000, 128_000),
"claude-opus-4-6": (1_000_000, 128_000),
"claude-sonnet-5": (1_000_000, 128_000),
"claude-sonnet-4-6": (1_000_000, 128_000),
"claude-fable-5": (1_000_000, 128_000),
"claude-haiku-4-5": (200_000, 64_000),
# OpenAI
"gpt-4o": (128_000, 16_384),
"gpt-4o-mini": (128_000, 16_384),
"gpt-4.1": (1_000_000, 32_768),
"gpt-4.1-mini": (1_000_000, 32_768),
# lokalne (Ollama/vLLM) — zwykle małe okno, dlatego ostrożna domyślna
"llama3.1": (8_192, 4_096),
"llama3.2": (8_192, 4_096),
"qwen2.5": (32_768, 8_192),
"mistral": (32_768, 8_192),
}
# gdy modelu nie ma w tabeli — zachowawczo, żeby nie obiecywać nieistniejącego okna
_FALLBACK: dict[str, tuple[int, int]] = {
"anthropic": (200_000, 32_000),
"openai": (128_000, 16_384),
"local": (8_192, 4_096),
}
# ile tokenów zostawiamy jako bufor na narzut protokołu i niedokładność liczenia
SAFETY_MARGIN = 2_000
# poniżej tylu tokenów wyjścia nie ma sensu wołać modelu — nie zmieści horoskopu
MIN_OUTPUT = 1_500
# powyżej tylu tokenów promptu ostrzegamy użytkownika (nadal pozwalając wysłać)
WARN_PROMPT_TOKENS = 90_000
def _env_int(provider: str, suffix: str) -> int | None:
raw = os.getenv(f"{provider.upper()}_{suffix}")
if not raw:
return None
try:
value = int(raw)
except ValueError:
return None
return value if value > 0 else None
def limits_for(provider: str, model: str) -> tuple[int, int]:
"""(okno kontekstu, maksymalne wyjście) dla modelu — z nadpisaniem z ENV.
Dopasowanie po prefiksie, bo nazwy modeli lokalnych niosą tag (`llama3.1:8b`).
"""
env_ctx = _env_int(provider, "CONTEXT_WINDOW")
env_out = _env_int(provider, "MAX_OUTPUT")
key = (model or "").strip().lower()
known: tuple[int, int] | None = _MODEL_LIMITS.get(key)
if known is None:
for name, pair in _MODEL_LIMITS.items():
if key.startswith(name):
known = pair
break
if known is None:
known = _FALLBACK.get(provider, _FALLBACK["local"])
return (env_ctx or known[0], env_out or known[1])
def plan(provider: str, model: str, prompt_tokens: int,
want_output: int | None = None) -> dict:
"""Ile tokenów wyjścia zamówić dla promptu tej wielkości.
Zwraca plan z jawną diagnostyką — UI ma z czego zbudować ostrzeżenie, a błąd
ma czym wytłumaczyć, dlaczego się nie udało.
"""
context_window, model_max_output = limits_for(provider, model)
room = context_window - prompt_tokens - SAFETY_MARGIN
target = want_output or model_max_output
max_output = max(0, min(model_max_output, target, room))
warnings: list[str] = []
if prompt_tokens > WARN_PROMPT_TOKENS:
warnings.append(
f"Prompt ma ~{prompt_tokens} tokenów — to dużo. Zapytanie zostanie wysłane, "
f"ale potrwa dłużej i będzie odpowiednio kosztowne."
)
if max_output < MIN_OUTPUT:
warnings.append(
f"Po zmieszczeniu promptu zostaje tylko {max_output} tokenów na odpowiedź "
f"(minimum {MIN_OUTPUT}). Zmniejsz budżet promptu albo wybierz model "
f"z większym oknem kontekstu."
)
return {
"provider": provider,
"model": model,
"context_window": context_window,
"model_max_output": model_max_output,
"prompt_tokens": prompt_tokens,
"max_output": max_output,
"fits": max_output >= MIN_OUTPUT,
"warnings": warnings,
}
def prompt_token_budget(provider: str, model: str, reserve_output: int | None = None) -> int:
"""Ile tokenów promptu wolno wysłać, ZAWSZE zostawiając miejsce na odpowiedź.
To jest podstawa opcji „maksymalny kontekst modelu" w UI.
"""
context_window, model_max_output = limits_for(provider, model)
reserve = reserve_output or model_max_output
return max(0, context_window - reserve - SAFETY_MARGIN)
+335
View File
@@ -0,0 +1,335 @@
"""Implementacje dostawców LLM (LOG-31) — na samym httpx, bez SDK.
Świadomie bez bibliotek `openai` / `anthropic`: lokalny serwer modelu (Ollama,
vLLM, llama.cpp) i OpenAI mówią **tym samym** protokołem `/chat/completions`,
więc jedna implementacja obsługuje oba — różni je tylko adres i klucz. Anthropic
ma własny kształt `/v1/messages`, stąd druga klasa. Mniej zależności, mniej
powierzchni ataku, pełna kontrola nad tym, co wychodzi z sieci.
**Gwarancja niepustej odpowiedzi.** Horoskop ma powstać niezależnie od objętości
promptu, więc `generate()` nie jest pojedynczym strzałem, tylko pętlą:
1. wyślij turę z policzonym limitem wyjścia,
2. jeśli model urwał na limicie — dopisz turę „kontynuuj" i sklej tekst,
3. jeśli tura nie dała ani znaku tekstu — ponów z podpowiedzią,
4. dopiero brak tekstu po wszystkich próbach jest błędem (z diagnostyką).
Kontynuacja jest pewniejsza niż jedno wielkie żądanie: każda tura mieści się
w timeoucie HTTP, a długość odpowiedzi przestaje być ograniczona jedną turą.
**Anthropic i myślenie.** Modele Claude potrafią mieć włączone myślenie, którego
tokeny liczą się do `max_tokens`. Przy ciasnym limicie cała tura potrafi wyjść
jako same bloki `thinking` z pustym tekstem — dokładnie ten objaw, który
zgłoszono. Traktujemy taką turę jak ucięcie i kontynuujemy, zamiast zwracać pustkę.
"""
from __future__ import annotations
import os
import time
import httpx
from app.llm.base import Completion, LLMError, LLMProvider
RETRY_STATUSES = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 3
# ile razy wolno poprosić model o dokończenie urwanej odpowiedzi
MAX_CONTINUATIONS = 12
# ile tokenów zamawiać na jedną turę — mieści się w timeoucie, a pętla i tak
# dociągnie resztę; zbyt duża wartość ryzykuje zerwanie połączenia w trakcie
TURN_TOKENS_CAP = 16_000
_CONTINUE = (
"Kontynuuj dokładnie od miejsca, w którym przerwałeś — nie powtarzaj tego, "
"co już napisałeś, i nie zaczynaj od nowa. Jeśli skończyłeś całą odpowiedź, "
"napisz wyłącznie: KONIEC"
)
_NUDGE = (
"Nie otrzymałem żadnej treści. Napisz odpowiedź zgodnie z powyższym poleceniem, "
"zaczynając od razu od treści horoskopu."
)
_DONE_MARKER = "KONIEC"
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
last: Exception | None = None
for attempt in range(MAX_ATTEMPTS):
try:
with httpx.Client(timeout=timeout) as client:
r = client.post(url, json=payload, headers=headers)
if r.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS - 1:
time.sleep(2 ** attempt)
continue
if r.status_code >= 400:
raise LLMError(f"Model odpowiedział błędem {r.status_code}: {r.text[:300]}")
return r.json()
except httpx.TimeoutException as e:
last = e
if attempt < MAX_ATTEMPTS - 1:
time.sleep(2 ** attempt)
continue
raise LLMError(
f"Model nie odpowiedział w czasie {timeout:.0f}s. Zwiększ LLM_TIMEOUT "
f"albo zmniejsz budżet promptu."
) from e
except httpx.HTTPError as e:
last = e
if attempt < MAX_ATTEMPTS - 1:
time.sleep(2 ** attempt)
continue
raise LLMError(f"Nie udało się połączyć z modelem: {e}") from e
raise LLMError(f"Nie udało się wywołać modelu: {last}")
def _merge_usage(total: dict, turn: dict) -> dict:
"""Sumuje zużycie tokenów przez wszystkie tury jednej odpowiedzi."""
for key, value in (turn or {}).items():
if isinstance(value, int):
total[key] = total.get(key, 0) + value
return total
def _join(parts: list[str]) -> str:
return "".join(parts).strip()
def _explain_empty(turns: int, usage: dict, stop: str | None) -> str:
detail = []
if stop:
detail.append(f"powód zakończenia: {stop}")
for key in ("completion_tokens", "output_tokens"):
if usage.get(key) is not None:
detail.append(f"tokeny odpowiedzi: {usage[key]}")
break
suffix = f" ({', '.join(detail)})" if detail else ""
return (
f"Model nie zwrócił żadnej treści po {turns} próbach{suffix}. "
f"Najczęstsza przyczyna: prompt wypełnił okno kontekstu i nie zostało miejsca "
f"na odpowiedź. Zmniejsz budżet promptu albo wybierz model z większym oknem."
)
class _Driver:
"""Wspólna pętla: tura → ewentualna kontynuacja → sklejony tekst.
Podklasy dostarczają tylko `_turn()` — reszta (kontynuacje, ponawianie pustej
tury, sumowanie zużycia) jest identyczna dla obu protokołów.
"""
name: str
model: str
leaves_lan: bool
def _turn(self, messages: list[dict], max_tokens: int):
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
raise NotImplementedError
def generate(self, prompt: str, max_tokens: int, on_event=None) -> Completion:
"""`on_event(dict)` dostaje zdarzenia postępu — UI pokazuje z nich log.
Raportujemy KAŻDĄ turę, bo to ona trwa; bez tego pasek postępu byłby
ozdobnikiem, a nie informacją."""
def emit(kind: str, message: str, **extra):
if on_event:
on_event({"type": kind, "message": message, **extra})
messages: list[dict] = [{"role": "user", "content": prompt}]
parts: list[str] = []
usage: dict = {}
model_name = self.model
remaining = max(max_tokens, 256)
stop: str | None = None
turns = 0
nudged = False
while turns <= MAX_CONTINUATIONS:
turns += 1
budget = max(256, min(remaining, TURN_TOKENS_CAP))
emit("turn_start",
f"Tura {turns}: wysyłam do modelu {self.model} (limit {budget} tokenów)…",
turn=turns)
started = time.monotonic()
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
took = time.monotonic() - started
_merge_usage(usage, turn_usage)
# Odejmujemy tokeny FAKTYCZNIE wyprodukowane, nie zamówiony limit tury.
# Inaczej pierwsza tura zjadałaby cały budżet i urwana odpowiedź nigdy
# nie doczekałaby się kontynuacji — wracałby do użytkownika fragment
# udający całość.
produced = (turn_usage or {}).get("completion_tokens")
if produced is None:
produced = (turn_usage or {}).get("output_tokens")
if produced is None:
produced = max(1, int(len(text) / 3.6))
remaining -= max(1, int(produced))
emit("turn_end",
f"Tura {turns}: odebrano {len(text.strip())} znaków w {took:.1f}s"
+ (" — odpowiedź urwana, poproszę o dokończenie" if truncated else ""),
turn=turns, chars=len(text.strip()), truncated=truncated)
chunk = text.strip()
if chunk:
if chunk.endswith(_DONE_MARKER): # model zgłasza koniec
parts.append(("\n" if parts else "") + chunk[: -len(_DONE_MARKER)].rstrip())
break
parts.append(("\n" if parts else "") + chunk)
if not truncated:
break
elif not truncated:
# pusta i NIE ucięta: jedna próba z podpowiedzią, potem koniec
if nudged or parts:
break
nudged = True
messages = messages + [
{"role": "assistant", "content": ""},
{"role": "user", "content": _NUDGE},
]
continue
# ucięta (także tura złożona z samego myślenia) — poproś o dokończenie
if remaining < 256:
break
messages = [
{"role": "user", "content": prompt},
{"role": "assistant", "content": _join(parts) or ""},
{"role": "user", "content": _CONTINUE},
]
final = _join(parts)
if not final:
raise LLMError(_explain_empty(turns, usage, stop))
emit("generated", f"Gotowe: {len(final)} znaków w {turns} turach.",
chars=len(final), turns=turns)
usage["turns"] = turns
return Completion(text=final, model=model_name, provider=self.name,
leaves_lan=self.leaves_lan, usage=usage)
def count_tokens(self, prompt: str) -> int:
"""Szacunek tokenów promptu. Dostawcy z własnym licznikiem nadpisują."""
return int(len(prompt) / 3.6)
class ChatCompletionsProvider(_Driver, LLMProvider):
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
timeout: float = 120.0, leaves_lan: bool = True) -> None:
self.name = name
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout = timeout
self.leaves_lan = leaves_lan
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.api_key:
h["Authorization"] = f"Bearer {self.api_key}"
return h
def _turn(self, messages: list[dict], max_tokens: int):
data = _post_with_retry(
f"{self.base_url}/chat/completions", self._headers(),
{"model": self.model, "max_tokens": max_tokens, "messages": messages},
self.timeout,
)
try:
choice = data["choices"][0]
text = choice["message"].get("content") or ""
except (KeyError, IndexError, TypeError) as e:
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
stop = choice.get("finish_reason")
return (text, stop == "length", data.get("usage") or {},
data.get("model", self.model), stop)
def health(self) -> dict:
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
try:
with httpx.Client(timeout=min(self.timeout, 10.0)) as client:
r = client.get(f"{self.base_url}/models", headers=self._headers())
info["status"] = "ok" if r.status_code < 400 else f"http {r.status_code}"
except httpx.HTTPError as e:
info["status"] = f"down: {e}"
return info
class AnthropicProvider(_Driver, LLMProvider):
"""Protokół Anthropic `/v1/messages`."""
leaves_lan = True
def __init__(self, base_url: str, model: str, api_key: str = "",
timeout: float = 120.0) -> None:
self.name = "anthropic"
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout = timeout
def _headers(self) -> dict:
return {
"Content-Type": "application/json",
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
}
def _thinking(self) -> dict:
"""Konfiguracja myślenia. Domyślnie adaptacyjne — podnosi jakość tekstu.
UWAGA: tokeny myślenia liczą się do `max_tokens`, więc przy ciasnym limicie
cała tura potrafi wyjść jako samo myślenie z pustym tekstem. Pętla
kontynuacji to obsługuje, ale ANTHROPIC_THINKING=off wyłącza myślenie,
gdy zależy nam na przewidywalnym zużyciu tokenów.
"""
mode = os.getenv("ANTHROPIC_THINKING", "adaptive").lower()
if mode in ("off", "disabled", "0", "false"):
return {"thinking": {"type": "disabled"}}
return {
"thinking": {"type": "adaptive"},
"output_config": {"effort": os.getenv("ANTHROPIC_EFFORT", "high")},
}
def _turn(self, messages: list[dict], max_tokens: int):
if not self.api_key:
raise LLMError("Brak ANTHROPIC_API_KEY — dostawca anthropic wymaga klucza.")
payload = {"model": self.model, "max_tokens": max_tokens, "messages": messages}
payload.update(self._thinking())
data = _post_with_retry(f"{self.base_url}/v1/messages", self._headers(),
payload, self.timeout)
try:
blocks = data["content"]
text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
except (KeyError, TypeError) as e:
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
stop = data.get("stop_reason")
# tura złożona z samego myślenia = budżet poszedł na rozumowanie; traktujemy
# jak ucięcie, żeby pętla poprosiła o treść zamiast zwrócić pustkę
thinking_only = not text.strip() and any(
b.get("type") in ("thinking", "redacted_thinking") for b in blocks
)
return (text, stop == "max_tokens" or thinking_only, data.get("usage") or {},
data.get("model", self.model), stop)
def count_tokens(self, prompt: str) -> int:
"""Dokładny licznik Anthropic — nie szacunek. Od tego zależy, czy po
zmieszczeniu promptu zostanie miejsce na odpowiedź."""
if not self.api_key:
return super().count_tokens(prompt)
try:
data = _post_with_retry(
f"{self.base_url}/v1/messages/count_tokens", self._headers(),
{"model": self.model, "messages": [{"role": "user", "content": prompt}]},
min(self.timeout, 30.0),
)
return int(data.get("input_tokens") or super().count_tokens(prompt))
except LLMError:
return super().count_tokens(prompt)
def health(self) -> dict:
return {
"provider": self.name, "model": self.model, "leaves_lan": True,
"status": "ok (klucz ustawiony)" if self.api_key else "brak ANTHROPIC_API_KEY",
}
+482
View File
@@ -0,0 +1,482 @@
"""Warstwa LOGICZNA — usługa HTTP.
W górę: udostępnia API dla warstwy prezentacji.
W dół: woła warstwę bazodanową (DataClient).
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
"""
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)
def query(req: QueryRequest) -> QueryResponse:
try:
return service.handle(req)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
@app.post("/chart/positions")
def chart_positions(req: PositionsRequest) -> dict:
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
opcjonalnie stacje planet (LOG-03, stations=true)."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
try:
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if req.stations:
from app.engine.stations import find_stations
for p in chart["positions"]:
st = find_stations(engine, moment, p["name"])
if st:
p["stations"] = st
return chart
@app.post("/chart/compare")
def chart_compare(req: PositionsRequest) -> dict:
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
Wymaga skonfigurowanego ENGINE_SWISSEPH_URL (silnik B). W przeciwnym razie
zwraca informację, że porównanie jest niedostępne.
"""
from app.engine.compare import compare_engines
from app.engine.factory import build_engine
from app.engine.models import ChartMoment
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
try:
report = compare_engines(build_engine("own"), build_engine("swisseph"), moment)
except (RuntimeError, httpx.HTTPError) as e:
raise HTTPException(status_code=503, detail=f"Silnik B niedostępny: {e}")
return report.summary()
class ReportRequest(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
limit: int = 5000
group: bool = False # grupowanie identycznych opisów
@app.post("/chart/report")
def chart_report(req: ReportRequest) -> dict:
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.significators import build_report
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
chart = build_chart(engine, moment) # pozycje z domami + aspekty
try:
report = build_report(
chart["positions"], DataClient(),
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
)
except httpx.HTTPError as e:
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
return {"engine": engine.name, **report}
class PromptRequest(BaseModel):
"""Wejście generatora promptu (LOG-29/30)."""
profile: str = "natal" # natal | period
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
budget: str = "medium" # concise | medium | extensive | huge | max
limit: int = 5000
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
model: str | None = None
# tylko dla profilu period:
from_date: str | None = None
to_date: str | None = None
techniques: list[str] | None = None
@app.post("/chart/prompt")
def chart_prompt(req: PromptRequest) -> dict:
"""Gotowy prompt do LLM z naszych wyliczeń (LOG-29) z budżetowaniem (LOG-30).
profile=natal → horoskop urodzeniowy (ekran Interpretacje)
profile=period → horoskop na wybrany okres (ekran Kalendarz)
Nie woła żadnego modelu — zwraca sam prompt i statystyki redukcji, żeby dało się
go obejrzeć i skopiować. Wysyłkę do modelu doda LOG-31.
"""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.prompt import CHARS_PER_TOKEN, MAX_BUDGET, build_natal_prompt, build_period_prompt
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
chart = build_chart(engine, moment)
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
data_error = None
# Budżet „maksymalny kontekst modelu": limit znaków liczymy z okna kontekstu
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
budget_chars = None
if req.budget == MAX_BUDGET:
from app.llm.factory import resolve_model
from app.llm.limits import prompt_token_budget
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
# więc nie może wymagać skonfigurowanego klucza API
provider_name, model_name = resolve_model(req.provider, req.model)
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
try:
if req.profile == "natal":
from app.significators import build_report
report: dict = {"objects": []}
try:
report = build_report(
chart["positions"], DataClient(),
aspects=chart.get("aspects"), per_object_limit=req.limit,
)
except httpx.HTTPError as e:
data_error = f"Warstwa danych niedostępna: {e}"
out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
elif req.profile == "period":
if not (req.from_date and req.to_date):
raise HTTPException(422, "profile=period wymaga from_date i to_date")
from app.engine import houses as H
from app.engine.timeline import build_timeline
from app.significators import interpret_events
ramc, eps = engine.sidereal(moment)
points = {"Asc": H.compute_asc(ramc, eps, moment.lat), "MC": H.compute_mc(ramc, eps)}
for p in engine.positions(moment):
points[p.name] = p.longitude
events = build_timeline(engine, moment, points, req.from_date, req.to_date,
req.techniques)
try:
interpret_events(events, DataClient())
except httpx.HTTPError as e:
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
out = build_period_prompt(chart, events, req.from_date, req.to_date,
req.budget, label, budget_chars)
else:
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
except ValueError as e: # nieznany budżet
raise HTTPException(422, str(e))
out["engine"] = engine.name
if data_error:
out["data_error"] = data_error
return out
class HoroscopeRequest(PromptRequest):
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
max_tokens: int | None = None
@app.post("/chart/horoscope")
def chart_horoscope(req: HoroscopeRequest) -> dict:
"""Napisany horoskop (LOG-31): prompt z LOG-29/30 → model → gotowy tekst.
Zwraca TAKŻE użyty prompt — również gdy wywołanie modelu padnie — żeby dało się
go obejrzeć i użyć ręcznie. `leaves_lan` mówi, czy treść baz opuściła naszą sieć
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
"""
from app.llm.base import LLMError
from app.llm.factory import build_provider
from app.llm.limits import plan
out = chart_prompt(req) # ten sam prompt co w podglądzie
try:
provider = build_provider(req.provider, req.model)
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
# w oknie kontekstu w ogóle zmieści się miejsce na horoskop.
prompt_tokens = provider.count_tokens(out["prompt"])
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
out["token_plan"] = budget
if budget["warnings"]:
out["warnings"] = budget["warnings"]
if not budget["fits"]:
out["llm_error"] = " ".join(budget["warnings"])
return out
result = provider.generate(out["prompt"], budget["max_output"])
except LLMError as e:
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
out["llm_error"] = str(e)
return out
out.update(
horoscope=result.text,
provider=result.provider,
model=result.model,
leaves_lan=result.leaves_lan,
usage=result.usage,
)
return out
@app.post("/chart/horoscope/stream")
def chart_horoscope_stream(req: HoroscopeRequest):
"""To samo co /chart/horoscope, ale strumieniuje POSTĘP w trakcie pracy.
Pisanie horoskopu trwa minutami — bez sygnału aplikacja wygląda na zawieszoną.
Strumień (NDJSON, jedna linia = jedno zdarzenie) niesie RZECZYWISTE etapy:
budowę promptu, limity modelu i każdą turę generowania. Ostatnie zdarzenie
(`result`) ma identyczny kształt co odpowiedź zwykłego endpointu.
"""
from fastapi.responses import StreamingResponse
from app.progress import stream
def work(emit) -> dict:
from app.llm.base import LLMError
from app.llm.factory import build_provider
from app.llm.limits import plan
emit({"type": "stage", "message": "Liczę horoskop i szukam wskazań w bazach…"})
out = chart_prompt(req)
st = out.get("stats", {})
emit({"type": "stage", "message":
f"Prompt gotowy: {st.get('chars', 0)} znaków, "
f"wskazań {st.get('included', 0)}"
+ (f", pominięto {st['omitted']}" if st.get("omitted") else "")})
if out.get("data_error"):
emit({"type": "warn", "message": out["data_error"]})
try:
provider = build_provider(req.provider, req.model)
emit({"type": "stage", "message":
f"Dostawca: {provider.name}, model: {provider.model}"
+ ("" if not provider.leaves_lan else " — dane opuszczają sieć")})
emit({"type": "stage", "message": "Liczę tokeny promptu…"})
prompt_tokens = provider.count_tokens(out["prompt"])
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
out["token_plan"] = budget
emit({"type": "stage", "message":
f"Prompt {prompt_tokens} tok. · okno modelu {budget['context_window']} · "
f"na odpowiedź {budget['max_output']}"})
for warning in budget["warnings"]:
emit({"type": "warn", "message": warning})
if budget["warnings"]:
out["warnings"] = budget["warnings"]
if not budget["fits"]:
out["llm_error"] = " ".join(budget["warnings"])
return out
result = provider.generate(out["prompt"], budget["max_output"], on_event=emit)
except LLMError as e:
emit({"type": "warn", "message": f"Model zawiódł: {e}"})
out["llm_error"] = str(e)
return out
out.update(horoscope=result.text, provider=result.provider, model=result.model,
leaves_lan=result.leaves_lan, usage=result.usage)
return out
return StreamingResponse(
stream(work),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
@app.get("/llm/models")
def llm_models() -> dict:
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
dowolny identyfikator, do którego konto ma dostęp.
"""
from app.llm.catalog import catalog
from app.llm.factory import _DEFAULT_MODEL
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
@app.get("/llm/health")
def llm_health(provider: str | None = None) -> dict:
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
from app.llm.base import LLMError
from app.llm.factory import build_provider
try:
return build_provider(provider).health()
except LLMError as e:
return {"status": f"blad konfiguracji: {e}"}
class ProfectionsRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
start_age: int = 0
count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy
@app.post("/chart/profections")
def chart_profections(req: ProfectionsRequest) -> dict:
"""Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo)."""
from app.engine import houses as H
from app.engine.models import ChartMoment
from app.engine.profections import profection_rows
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
points = {
"Asc": H.compute_asc(ramc, eps, natal.lat),
"MC": H.compute_mc(ramc, eps),
}
for p in engine.positions(natal, ["Sun", "Moon"]):
points[p.name] = p.longitude
rows = profection_rows(points, req.when_utc, req.start_age, req.count)
return {"engine": engine.name, "rows": rows}
class ReturnRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
kind: str = "solar" # solar | lunar
around: datetime | None = None # data, wokół której szukać powrotu
@app.post("/chart/return")
def chart_return(req: ReturnRequest) -> dict:
"""Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.engine.returns import find_return
if req.kind not in ("solar", "lunar"):
raise HTTPException(status_code=422, detail="kind: solar albo lunar")
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
around = req.around or req.when_utc
hit = find_return(engine, req.kind, natal, around)
if hit is None:
raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu")
chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon))
return {"engine": engine.name, "kind": req.kind,
"return_utc": hit.isoformat(), **chart}
class FirdariaRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
@app.post("/chart/firdaria")
def chart_firdaria(req: FirdariaRequest) -> dict:
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
from app.engine import houses as H
from app.engine.firdaria import firdaria
from app.engine.models import ChartMoment
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
sun = engine.positions(natal, ["Sun"])[0].longitude
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
class TimelineRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
from_date: str # zakres: YYYY-MM-DD
to_date: str
techniques: list[str] | None = None # profection | solar_return | solar_arc
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
@app.post("/chart/timeline")
def chart_timeline(req: TimelineRequest) -> dict:
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
"""
from app.engine import houses as H
from app.engine.models import ChartMoment
from app.engine.timeline import build_timeline
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
for p in engine.positions(natal):
points[p.name] = p.longitude
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
if req.interpret:
from app.significators import interpret_events
try:
interpret_events(events, DataClient())
except httpx.HTTPError as e:
out["data_error"] = f"Warstwa danych niedostępna: {e}"
out.update(count=len(events), events=events)
return out
@app.get("/health")
def health() -> dict:
info = {"status": "ok", "layer": "logic"}
try:
info["data_layer"] = DataClient().health()
except httpx.HTTPError as e:
info["data_layer"] = {"status": "down", "error": str(e)}
return info
+25
View File
@@ -0,0 +1,25 @@
"""Kontrakt warstwy logicznej (widziany przez warstwę prezentacji)."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
"""To, co przychodzi z formularza (przez warstwę prezentacji)."""
query: str = Field(..., min_length=1, description="Szukana fraza.")
field: str = Field("name", description="Po którym polu szukać.")
exact: bool = False
limit: int = Field(25, ge=1, le=200)
class QueryResponse(BaseModel):
"""To, co wraca w górę do prezentacji."""
status: str = "ok"
query: str
count: int
results: list[dict[str, Any]]
meta: dict[str, Any] = Field(default_factory=dict)
+70
View File
@@ -0,0 +1,70 @@
"""Strumień postępu długiej operacji (NDJSON).
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
zwykłym `fetch()` i jest trywialny do sparsowania.
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
"""
from __future__ import annotations
import json
import queue
import threading
import traceback
from collections.abc import Iterator
from typing import Any, Callable
_HEARTBEAT_SECONDS = 10.0
_DONE = object()
def line(kind: str, message: str, **extra: Any) -> str:
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
— połączenie nigdy nie urywa się bez wyjaśnienia.
"""
events: queue.Queue = queue.Queue()
def emit(event: dict) -> None:
events.put(event)
def run() -> None:
try:
result = work(emit)
events.put({"type": "result", "message": "Gotowe.", "result": result})
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
events.put({
"type": "error",
"message": f"{type(e).__name__}: {e}",
"detail": traceback.format_exc(limit=3),
})
finally:
events.put(_DONE)
worker = threading.Thread(target=run, daemon=True)
worker.start()
while True:
try:
event = events.get(timeout=_HEARTBEAT_SECONDS)
except queue.Empty:
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
# (proxy, load balancer) nie uznały połączenia za martwe
yield line("ping", "")
continue
if event is _DONE:
break
yield json.dumps(event, ensure_ascii=False) + "\n"
+352
View File
@@ -0,0 +1,352 @@
"""Generator promptów do LLM (LOG-29) + budżetowanie rozmiaru (LOG-30).
Składa z naszych wyliczeń gotowe, profesjonalne polecenie po polsku:
- profil **natal** — horoskop urodzeniowy (na bazie /chart/report),
- profil **period** — horoskop na wybrany okres (na bazie /chart/timeline).
Zasada naczelna promptu: model ma pisać WYŁĄCZNIE na podstawie dostarczonych danych
i przy każdej tezie wskazać konkretny sygnifikator, z którego ona wynika. To odróżnia
wynik od ogólnikowej wróżby i pozwala go zweryfikować.
Budżetowanie (LOG-30) — kolejność redukcji:
1. deduplikacja (ten sam sygnifikator ORAZ ten sam opis),
2. grupowanie identycznych opisów z licznikiem wystąpień,
3. sortowanie malejąco wg punktacji siły (LOG-21),
4. obcięcie ogona do budżetu — jednostką obcięcia jest CAŁE wskazanie,
5. skracanie nadmiernie długich opisów (z jawnym oznaczeniem).
Zawsze raportujemy, ile wskazań weszło i ile pominięto — użytkownik ma wiedzieć,
czego brakuje, i móc zwiększyć budżet.
Generowanie jest deterministyczne: ten sam horoskop + ten sam budżet = ten sam prompt.
"""
from __future__ import annotations
import re
# budżety w znakach (całego promptu); dobrane pod wklejanie do ChatGPT/Claude
BUDGETS: dict[str, int] = {
"concise": 4_000,
"medium": 12_000,
"extensive": 30_000,
"huge": 120_000,
# „maksymalny kontekst modelu" — wyliczany dynamicznie z okna kontekstu
# wybranego modelu, ZAWSZE po odjęciu miejsca zarezerwowanego na odpowiedź.
# Wartość poniżej jest tylko zapasem, gdy limity modelu są nieznane.
"max": 400_000,
}
DEFAULT_BUDGET = "medium"
MAX_BUDGET = "max"
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI
DISCLAIMER = (
"Zakończ krótką notą: treść jest interpretacją astrologiczną i nie stanowi porady "
"medycznej, prawnej ani finansowej."
)
def _norm(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
def est_tokens(text: str) -> int:
return int(len(text) / CHARS_PER_TOKEN)
def _shorten(text: str, limit: int = MAX_EFFECT_CHARS) -> tuple[str, bool]:
text = (text or "").strip()
if len(text) <= limit:
return text, False
return text[:limit].rstrip() + " […skrócono]", True
# ----------------------------------------------------------------- wskazania
def natal_indications(report: dict) -> list[dict]:
"""Płaska lista wskazań z raportu natalnego: obiekt + faseta + opis + waga."""
out: list[dict] = []
for obj in report.get("objects") or []:
name = obj.get("object")
for facet in obj.get("facets") or []:
score = float(facet.get("score") or 0.0)
for s in facet.get("samples") or []:
out.append({
"context": f"{name}{facet.get('label')}",
"sort_key": (name or "", str(facet.get("label") or "")),
"score": score,
"significator": s.get("expanded") or s.get("significator") or "",
"effect": s.get("effect") or "",
})
return out
def period_indications(events: list[dict]) -> list[dict]:
"""Wskazania z osi czasu — waga rośnie z liczbą trafień w bazie."""
out: list[dict] = []
for ev in events or []:
head = f"{ev.get('technique')} · {ev.get('significator')} · {ev.get('exact')}"
total = float(ev.get("interpretations_count") or 0)
for s in ev.get("interpretations") or []:
out.append({
"context": head,
"sort_key": (str(ev.get("exact") or ""), str(ev.get("technique") or "")),
"score": total,
"significator": s.get("expanded") or s.get("significator") or "",
"effect": s.get("effect") or "",
"date": ev.get("exact"),
"start": ev.get("start"),
"end": ev.get("end"),
"technique": ev.get("technique"),
})
return out
def reduce_indications(items: list[dict], budget_chars: int) -> tuple[list[dict], dict]:
"""Dedup → grupowanie → sortowanie wg wagi → obcięcie do budżetu.
Zwraca (wybrane wskazania, statystyki). Jednostką obcięcia jest całe wskazanie.
"""
# 1+2: dedup i grupowanie identycznych (kontekst, opis) z licznikiem
grouped: dict[tuple[str, str], dict] = {}
order: list[tuple[str, str]] = []
for it in items:
key = (_norm(it["context"]), _norm(it["effect"]))
g = grouped.get(key)
if g is None:
g = {**it, "count": 0, "significators": []}
grouped[key] = g
order.append(key)
g["count"] += 1
if it["significator"] not in g["significators"]:
g["significators"].append(it["significator"])
merged = [grouped[k] for k in order]
deduped = len(items) - len(merged)
# 3: sortowanie malejąco wg wagi; remis rozstrzygany deterministycznie
merged.sort(key=lambda g: (-g["score"], -g["count"], g["sort_key"], g["significator"]))
# 5 (przed obcięciem, bo wpływa na rozmiar): skracanie długich opisów
shortened = 0
for g in merged:
g["effect"], was = _shorten(g["effect"])
shortened += 1 if was else 0
# 4: obcięcie ogona do budżetu — cała pozycja albo nic
chosen: list[dict] = []
used = 0
for g in merged:
cost = len(_render_indication(g)) + 1
if used + cost > budget_chars and chosen:
break
chosen.append(g)
used += cost
omitted = len(merged) - len(chosen)
stats = {
"source_rows": len(items),
"after_grouping": len(merged),
"deduplicated": deduped,
"included": len(chosen),
"omitted": omitted,
"shortened": shortened,
"min_score_included": round(chosen[-1]["score"], 3) if chosen else None,
"max_score_omitted": round(merged[len(chosen)]["score"], 3) if omitted else None,
}
return chosen, stats
def _render_indication(g: dict) -> str:
times = f" (×{g['count']})" if g.get("count", 1) > 1 else ""
sig = g.get("significator") or ""
return f"- {sig}{g.get('effect')}{times}"
def _render_grouped(chosen: list[dict]) -> str:
"""Renderuje wskazania pogrupowane po kontekście, zachowując kolejność wagi."""
blocks: list[str] = []
seen: dict[str, list[dict]] = {}
order: list[str] = []
for g in chosen:
ctx = g["context"]
if ctx not in seen:
seen[ctx] = []
order.append(ctx)
seen[ctx].append(g)
for ctx in order:
rows = seen[ctx]
blocks.append(f"## {ctx} [waga {rows[0]['score']:.2f}]")
blocks.extend(_render_indication(g) for g in rows)
blocks.append("")
return "\n".join(blocks).rstrip()
# ------------------------------------------------------------- sekcje danych
def _chart_section(chart: dict, moment_label: str | None) -> str:
lines: list[str] = ["# DANE HOROSKOPU"]
meta = []
if moment_label:
meta.append(f"moment: {moment_label}")
if chart.get("zodiac"):
z = chart["zodiac"]
if chart.get("ayanamsha") is not None:
z += f" (ayanamsa {chart['ayanamsha']:.4f}°)"
meta.append(f"zodiak: {z}")
if chart.get("house_system"):
meta.append(f"system domów: {chart['house_system']}")
if chart.get("sect"):
meta.append(f"sekta: {'dzienna' if chart['sect'] == 'day' else 'nocna'}")
if meta:
lines.append(" · ".join(meta))
if chart.get("positions"):
lines.append("\n## Pozycje")
for p in chart["positions"]:
house = f"dom {p['house']}" if p.get("house") else ""
lines.append(f"{p['name']:<12} {p.get('in_sign',''):<16} {house:<8} {p.get('direction','')}")
angles = chart.get("angles") or {}
if angles:
lines.append("\n## Osie")
for key in ("Asc", "MC", "Dsc", "IC"):
a = angles.get(key)
if a:
lines.append(f"{a['name']:<5} {a.get('in_sign','')}")
if chart.get("lots"):
lines.append("\n## Lots (punkty arabskie)")
for lot in chart["lots"]:
lines.append(
f"{lot['name']:<10} {lot.get('in_sign',''):<16} dom {lot.get('house','')}"
f" ({lot.get('formula','')})"
)
if chart.get("aspects"):
lines.append("\n## Aspekty (orb; A = aplikacyjny, S = separacyjny)")
for a in chart["aspects"]:
mark = a.get("as") or ""
lines.append(
f"{a['obj1']} {a['aspect']} {a['obj2']} orb {a.get('orb', 0):.2f}° {mark}"
)
return "\n".join(lines)
def _indications_section(chosen: list[dict], stats: dict) -> str:
if not chosen:
return (
"# WSKAZANIA Z BAZ\n"
"(brak trafień w bazach dla tego horoskopu — oprzyj interpretację wyłącznie "
"na danych horoskopu powyżej)"
)
head = [
"# WSKAZANIA Z BAZ INTERPRETACYJNYCH",
"Wskazania dopasowane do tego horoskopu, uporządkowane od najsilniejszych.",
"Zapis: `- sygnifikator → opis (×ile razy wystąpiło w bazach)`.",
]
if stats.get("omitted"):
head.append(
f"UWAGA: pokazano {stats['included']} najsilniejszych wskazań, pominięto "
f"{stats['omitted']} słabszych (limit długości)."
)
return "\n".join(head) + "\n\n" + _render_grouped(chosen)
# ------------------------------------------------------------------ prompty
_NATAL_TASK = """# ZADANIE
Jesteś doświadczonym astrologiem. Napisz profesjonalną interpretację HOROSKOPU
URODZENIOWEGO po polsku, opierając się wyłącznie na danych podanych niżej."""
_PERIOD_TASK = """# ZADANIE
Jesteś doświadczonym astrologiem. Napisz profesjonalną prognozę astrologiczną
NA WYBRANY OKRES po polsku, opierając się wyłącznie na danych podanych niżej."""
_NATAL_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
1. Struktura: (a) portret ogólny, (b) temperament i sekta, (c) obszary życia według domów,
(d) napięcia i wyzwania, (e) zasoby i mocne strony, (f) zwięzłe podsumowanie.
2. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie, np. „(Moon in 12th house)".
Teza bez wskazania jest niedopuszczalna.
3. Nie dodawaj twierdzeń, których nie da się wywieść z powyższych danych. Nie zmyślaj
pozycji, aspektów ani wskazań; nie korzystaj z wiedzy spoza tego promptu.
4. Gdy wskazania są sprzeczne, powiedz to wprost i wskaż obie strony, zamiast wybierać jedną.
5. Wagę wskazania traktuj jako siłę świadectwa — mocniejsze mają pierwszeństwo w syntezie.
6. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
_PERIOD_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
1. Uporządkuj prognozę CHRONOLOGICZNIE; przy każdym okresie podaj daty (start / dokładna / koniec).
2. Dla każdej daty napisz, która technika ją wyznacza (profekcja, solariusz, dyrekcja solar-arc,
Firdaria) i co z niej wynika.
3. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie. Teza bez wskazania jest
niedopuszczalna.
4. Nie zmyślaj dat ani zdarzeń spoza podanych. Nie korzystaj z wiedzy spoza tego promptu.
5. Rozróżniaj okresy o mocnym świadectwie (wysoka waga, kilka technik zbieżnych w czasie)
od słabych — i powiedz wprost, które są które.
6. Na końcu dodaj krótkie zestawienie: najważniejsze okresy w kolejności ważności.
7. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str:
return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"])
def _budget_chars(budget: str, budget_chars: int | None = None) -> int:
"""Limit znaków promptu. `budget_chars` nadpisuje tabelę — używane dla opcji
„maksymalny kontekst modelu", gdzie limit zależy od wybranego modelu i musi
być policzony po odjęciu miejsca zarezerwowanego na odpowiedź."""
if budget not in BUDGETS:
raise ValueError(
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
)
if budget_chars and budget_chars > 0:
return budget_chars
return BUDGETS[budget]
def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) -> dict:
stats = {
**stats,
"profile": profile,
"budget": budget,
"limit_chars": limit,
"chars": len(prompt),
"est_tokens": est_tokens(prompt),
}
return {"profile": profile, "prompt": prompt, "stats": stats}
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
moment_label: str | None = None,
budget_chars: int | None = None) -> dict:
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
limit = _budget_chars(budget, budget_chars)
chart_sec = _chart_section(chart, moment_label)
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
prompt = _assemble(_NATAL_TASK, chart_sec, _indications_section(chosen, stats), _NATAL_OUTPUT)
return _finish(prompt, budget, limit, stats, "natal")
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
budget: str = DEFAULT_BUDGET, moment_label: str | None = None,
budget_chars: int | None = None) -> dict:
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
limit = _budget_chars(budget, budget_chars)
chart_sec = _chart_section(chart, moment_label)
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date}{to_date}**."
ev_lines = ["# OŚ CZASU (techniki predykcyjne)",
"Zapis: technika · sygnifikator · start → dokładna → koniec."]
for ev in events or []:
ev_lines.append(
f"- {ev.get('technique')} · {ev.get('significator')} · "
f"{ev.get('start')}{ev.get('exact')}{ev.get('end')}"
)
events_sec = "\n".join(ev_lines)
fixed = len(task) + len(chart_sec) + len(events_sec) + len(_PERIOD_OUTPUT) + len(DISCLAIMER) + 200
chosen, stats = reduce_indications(period_indications(events), max(limit - fixed, 500))
body = events_sec + "\n\n" + _indications_section(chosen, stats)
prompt = _assemble(task, chart_sec, body, _PERIOD_OUTPUT)
stats["events"] = len(events or [])
return _finish(prompt, budget, limit, stats, "period")
+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)
+43
View File
@@ -0,0 +1,43 @@
"""Logika biznesowa — serce warstwy logicznej.
Tu (a nie w prezentacji ani w bazie) żyją reguły: walidacja/normalizacja danych
z formularza, tłumaczenie zapytania użytkownika na znormalizowane zapytanie do
bazy, oraz opracowanie/wzbogacenie wyników w drodze w górę.
To jest miejsce do rozbudowy o właściwą domenę (obliczenia, reguły, agregacje).
"""
from __future__ import annotations
from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
class QueryService:
def __init__(self, data_client: DataClient | None = None) -> None:
self.data = data_client or DataClient()
def handle(self, req: QueryRequest) -> QueryResponse:
# 1) normalizacja wejścia z formularza (reguła biznesowa)
value = req.query.strip()
key = req.field.strip().lower()
# 2) zapytanie w dół do warstwy bazodanowej
raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
results = raw.get("rows", [])
results = sorted(results, key=lambda r: str(r.get(key, "")))
return QueryResponse(
status="ok",
query=value,
count=len(results),
results=results,
meta={
"field": key,
"exact": req.exact,
"data_cache": raw.get("cache"),
"data_provider": raw.get("provider"),
"data_elapsed_ms": raw.get("elapsed_ms"),
},
)
+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
+6
View File
@@ -0,0 +1,6 @@
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
+91
View File
@@ -0,0 +1,91 @@
"""Okna kontekstu i planowanie budżetu tokenów (LOG-30/31).
Naczelna zasada, której pilnują te testy: **zawsze zostaje miejsce na odpowiedź**.
Prompt nigdy nie może wypełnić całego okna kontekstu, bo wtedy model kończy na
`max_tokens` z pustą albo uciętą treścią to był zgłoszony błąd.
"""
import pytest
from app.llm import limits
def test_known_models_have_documented_limits():
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
assert ctx == 1_000_000 and out == 128_000
ctx, out = limits.limits_for("anthropic", "claude-haiku-4-5")
assert ctx == 200_000 and out == 64_000
def test_local_model_tag_is_matched_by_prefix():
"""Modele lokalne niosą tag (`llama3.1:8b`) — dopasowanie musi to znieść."""
assert limits.limits_for("local", "llama3.1:8b") == limits.limits_for("local", "llama3.1")
def test_unknown_model_falls_back_conservatively():
ctx, out = limits.limits_for("local", "jakis-egzotyczny-model")
assert ctx == 8_192 and out == 4_096
def test_env_overrides_win(monkeypatch):
"""Modele wychodzą szybciej, niż aktualizuje się tabela."""
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "131072")
monkeypatch.setenv("LOCAL_MAX_OUTPUT", "8192")
assert limits.limits_for("local", "llama3.1:8b") == (131_072, 8_192)
def test_env_override_ignores_garbage(monkeypatch):
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "nie-liczba")
assert limits.limits_for("local", "llama3.1:8b")[0] == 8_192
# ------------------------------------------------- rezerwa miejsca na odpowiedź
def test_prompt_budget_always_reserves_room_for_answer():
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
budget = limits.prompt_token_budget("anthropic", "claude-opus-4-8")
assert budget + out + limits.SAFETY_MARGIN <= ctx
assert budget > 0
def test_small_context_model_still_leaves_room():
budget = limits.prompt_token_budget("local", "llama3.1:8b")
ctx, out = limits.limits_for("local", "llama3.1:8b")
assert budget + out + limits.SAFETY_MARGIN <= ctx
def test_plan_shrinks_output_when_prompt_is_huge():
"""Duży prompt nie może dostać pełnego okna wyjścia — musi się zmieścić."""
ctx, model_out = limits.limits_for("local", "llama3.1:8b")
p = limits.plan("local", "llama3.1:8b", prompt_tokens=6_000)
assert p["max_output"] <= ctx - 6_000 - limits.SAFETY_MARGIN
assert p["max_output"] < model_out
def test_plan_reports_not_fitting_instead_of_failing_silently():
p = limits.plan("local", "llama3.1:8b", prompt_tokens=8_000)
assert p["fits"] is False
assert any("odpowied" in w for w in p["warnings"])
def test_plan_warns_above_90k_but_still_fits():
"""Powyżej 90 tys. tokenów ostrzegamy — ale wysłanie MA być nadal możliwe."""
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=120_000)
assert p["fits"] is True, "duży prompt nadal musi dać się wysłać"
assert p["max_output"] >= limits.MIN_OUTPUT
assert any("dużo" in w for w in p["warnings"])
def test_no_warning_below_threshold():
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=10_000)
assert p["warnings"] == [] and p["fits"] is True
def test_requested_output_is_capped_by_model_maximum():
p = limits.plan("anthropic", "claude-haiku-4-5", prompt_tokens=1_000, want_output=999_999)
assert p["max_output"] == 64_000
@pytest.mark.parametrize("tokens", [0, 1_000, 50_000, 200_000, 900_000])
def test_plan_never_returns_negative_output(tokens):
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=tokens)
assert p["max_output"] >= 0
+437
View File
@@ -0,0 +1,437 @@
"""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 json
import httpx
import pytest
from app.llm import factory
from app.llm.base import Completion, LLMError
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
def _mock_client(handler):
"""Podmienia httpx.Client na wersję z transportem testowym."""
class _C(httpx.Client):
def __init__(self, *a, **kw):
kw["transport"] = httpx.MockTransport(handler)
super().__init__(*a, **kw)
return _C
@pytest.fixture
def chat_ok(monkeypatch):
def handler(request):
assert request.url.path.endswith("/chat/completions")
return httpx.Response(200, json={
"model": "test-model",
"choices": [{"message": {"content": "Horoskop testowy."}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 50},
})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
# ------------------------------------------------------- protokół chat/completions
def test_local_provider_generates(chat_ok):
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
out = p.generate("prompt", 500)
assert isinstance(out, Completion)
assert out.text == "Horoskop testowy."
assert out.usage["completion_tokens"] == 50
def test_local_provider_does_not_leave_lan(chat_ok):
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
assert p.generate("prompt", 100).leaves_lan is False
def test_cloud_provider_marks_leaving_lan(chat_ok):
p = ChatCompletionsProvider("openai", "https://api.openai.com/v1", "m", "klucz",
leaves_lan=True)
assert p.generate("prompt", 100).leaves_lan is True
def test_api_key_sent_only_when_set(monkeypatch):
seen = {}
def handler(request):
seen["auth"] = request.headers.get("authorization")
return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}]})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
assert seen["auth"] is None
ChatCompletionsProvider("openai", "http://x/v1", "m", "tajny").generate("p", 10)
assert seen["auth"] == "Bearer tajny"
def test_http_error_becomes_readable_message(monkeypatch):
monkeypatch.setattr(httpx, "Client",
_mock_client(lambda r: httpx.Response(400, text="zly model")))
with pytest.raises(LLMError, match="400"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
def test_malformed_response_reported(monkeypatch):
monkeypatch.setattr(httpx, "Client",
_mock_client(lambda r: httpx.Response(200, json={"nonsens": 1})))
with pytest.raises(LLMError, match="kształt"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
def test_retries_then_succeeds(monkeypatch):
calls = {"n": 0}
def handler(request):
calls["n"] += 1
if calls["n"] < 3:
return httpx.Response(429, text="za duzo")
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
monkeypatch.setattr("app.llm.providers.time.sleep", lambda s: None) # bez czekania
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10).text == "ok"
assert calls["n"] == 3
# ------------------------------------------------------------------- Anthropic
def test_anthropic_generates(monkeypatch):
def handler(request):
assert request.url.path.endswith("/v1/messages")
assert request.headers.get("x-api-key") == "klucz"
assert request.headers.get("anthropic-version")
return httpx.Response(200, json={
"model": "claude-x",
"content": [{"type": "text", "text": "Prognoza."}],
"usage": {"input_tokens": 10, "output_tokens": 5},
})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
assert out.text == "Prognoza." and out.leaves_lan is True
def test_anthropic_requires_key():
with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"):
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
# --------------------------------------------------------------------- fabryka
def test_default_provider_is_local(monkeypatch):
monkeypatch.delenv("LLM_PROVIDER", raising=False)
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("LLM_BASE_URL", raising=False)
p = factory.build_provider()
assert p.name == "local"
# domyślnie NIC nie opuszcza sieci — prompt niesie opisy z baz (LOG-32)
assert p.leaves_lan is False
def test_openai_requires_key(monkeypatch):
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
factory.build_provider("openai")
def test_unknown_provider_rejected():
with pytest.raises(LLMError, match="Nieznany dostawca"):
factory.build_provider("bzdura")
def test_env_overrides_model_and_url(monkeypatch):
monkeypatch.setenv("LLM_MODEL", "moj-model")
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
p = factory.build_provider("local")
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
# ---------------------------------------- konfiguracja per dostawca (regresja LOG-31)
# UI pozwala przelaczac dostawce przy kazdym zadaniu, wiec ustawienia JEDNEGO nie moga
# przeciekac na pozostalych. Wczesniej wspolne LLM_BASE_URL/LLM_MODEL kierowaly zadania
# do OpenAI na adres lokalnej Ollamy i prosily Anthropic o model llama.
def _clear(monkeypatch):
for v in ("LLM_PROVIDER", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_KEY",
"LOCAL_MODEL", "LOCAL_BASE_URL", "LOCAL_API_KEY",
"OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY",
"ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
monkeypatch.delenv(v, raising=False)
def test_local_config_does_not_leak_to_cloud(monkeypatch):
"""Sedno bledu: skonfigurowany model lokalny przejmowal zadania do chmury."""
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_BASE_URL", "http://ollama:11434/v1") # konfiguracja lokalnego
monkeypatch.setenv("LLM_MODEL", "llama3.1:8b")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
local = factory.build_provider("local")
assert local.base_url == "http://ollama:11434/v1" and local.model == "llama3.1:8b"
openai = factory.build_provider("openai")
assert openai.base_url == "https://api.openai.com/v1", "zadanie do OpenAI poszloby do Ollamy"
assert openai.model == "gpt-4o-mini", "OpenAI dostalby nazwe modelu llama"
def test_provider_specific_settings_win(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
p = factory.build_provider("anthropic")
assert p.model == "claude-opus-4-8" and p.api_key == "sk-ant"
def test_generic_vars_apply_only_to_default_provider(monkeypatch):
"""Zgodnosc wstecz: wspolne LLM_* konfiguruja dostawce domyslnego i tylko jego."""
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "openai")
monkeypatch.setenv("LLM_API_KEY", "sk-generic")
monkeypatch.setenv("LLM_MODEL", "gpt-4o")
assert factory.build_provider("openai").model == "gpt-4o"
assert factory.build_provider("local").model == "llama3.1:8b" # nie dziedziczy
def test_cloud_without_key_is_rejected_clearly(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
for name in ("openai", "anthropic"):
with pytest.raises(LLMError, match=f"{name.upper()}_API_KEY"):
factory.build_provider(name)
def test_local_needs_no_key(monkeypatch):
_clear(monkeypatch)
assert factory.build_provider("local").api_key == ""
# ------------------------------------------- pusta odpowiedz modelu (cicha awaria)
# Regresja: model potrafi oddac pusta tresc (prompt zjadl caly kontekst ->
# finish_reason=length, completion_tokens=0). Wczesniej generate() zwracalo pusty
# tekst BEZ bledu, widok nic nie renderowal i uzytkownik dostawal pusta strone
# bez zadnego wyjasnienia. Pusta odpowiedz MUSI byc bledem.
def test_empty_completion_raises_instead_of_silent_blank(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "llama3.1:8b",
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
def test_empty_completion_explains_context_window(monkeypatch):
"""Komunikat ma prowadzic do przyczyny, a nie tylko stwierdzac fakt."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": " "}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError) as ei:
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
msg = str(ei.value)
assert "kontekstu" in msg and "budżet" in msg
assert "powód zakończenia: length" in msg # diagnostyka w tresci bledu
def test_whitespace_only_is_treated_as_empty(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": "\n\n \t "}}],
})))
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100)
def test_anthropic_empty_completion_raises(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "claude-x", "content": [], "stop_reason": "max_tokens",
"usage": {"input_tokens": 9000, "output_tokens": 0},
})))
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
def test_normal_response_still_passes(monkeypatch):
"""Straznik nie moze psuc poprawnej odpowiedzi."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": "Horoskop."}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
})))
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100).text == "Horoskop."
# ------------------------------------- kontynuacja: horoskop MA powstac zawsze
# Sedno wymagania: niezaleznie od objetosci promptu i limitu wyjscia, pelna tresc
# ma wrocic do uzytkownika. Model urwany na max_tokens jest proszony o dokonczenie
# w ramach tej samej rozmowy, a kawalki sa sklejane.
def _scripted(responses):
"""Transport oddajacy kolejne odpowiedzi z listy (po jednej na ture)."""
seq = list(responses)
seen = []
def handler(request):
seen.append(request)
return httpx.Response(200, json=seq.pop(0) if seq else seq_last)
seq_last = responses[-1]
return handler, seen
def _chat(text, finish):
return {"choices": [{"message": {"content": text}, "finish_reason": finish}],
"usage": {"completion_tokens": 10}}
def test_truncated_answer_is_continued_and_joined(monkeypatch):
handler, seen = _scripted([
_chat("Czesc pierwsza.", "length"),
_chat("Czesc druga. KONIEC", "stop"),
])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
assert "Czesc pierwsza." in out.text and "Czesc druga." in out.text
assert "KONIEC" not in out.text # znacznik nie trafia do horoskopu
assert out.usage["turns"] == 2
def test_continuation_asks_in_same_conversation(monkeypatch):
"""Kontynuacja musi isc jako kolejna tura rozmowy, a ostatnia wiadomosc MUSI
byc od uzytkownika Claude odrzuca prefill w turze asystenta (400)."""
handler, seen = _scripted([_chat("Poczatek", "length"), _chat("Reszta", "stop")])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
msgs = json.loads(seen[1].content)["messages"]
assert msgs[-1]["role"] == "user", "ostatnia wiadomosc nie moze byc prefillem asystenta"
assert msgs[1]["role"] == "assistant" and "Poczatek" in msgs[1]["content"]
def test_anthropic_thinking_only_turn_is_continued(monkeypatch):
"""DOKLADNIE zgloszony objaw: cala tura poszla na myslenie, tekst pusty.
Wczesniej konczylo sie to pusta strona; teraz pytamy o tresc dalej."""
seq = [
{"content": [{"type": "thinking", "thinking": ""}], "stop_reason": "max_tokens",
"usage": {"output_tokens": 2000}},
{"content": [{"type": "text", "text": "Horoskop urodzeniowy..."}],
"stop_reason": "end_turn", "usage": {"output_tokens": 500}},
]
def handler(request):
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 40000)
assert out.text == "Horoskop urodzeniowy..."
assert out.usage["turns"] == 2
def test_anthropic_sends_thinking_config(monkeypatch):
"""Bez jawnego `thinking` Sonnet 5 wlacza myslenie sam — konfigurujemy to wprost."""
seen = []
def handler(request):
seen.append(json.loads(request.content))
return httpx.Response(200, json={"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn"})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
monkeypatch.delenv("ANTHROPIC_THINKING", raising=False)
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
assert seen[0]["thinking"] == {"type": "adaptive"}
seen.clear()
monkeypatch.setenv("ANTHROPIC_THINKING", "off")
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
assert seen[0]["thinking"] == {"type": "disabled"}
def test_complete_answer_does_not_loop(monkeypatch):
"""Straznik nie moze mnozyc zapytan, gdy model skonczyl normalnie."""
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return httpx.Response(200, json=_chat("Gotowe.", "stop"))
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 40000)
assert out.text == "Gotowe." and calls["n"] == 1
# ------------------------------------------- wybor modelu przez uzytkownika (UI)
def test_model_from_request_wins_over_config(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
p = factory.build_provider("anthropic", "claude-fable-5")
assert p.model == "claude-fable-5", "wybor z UI musi wygrac nad konfiguracja"
def test_blank_model_falls_back_to_configured_default(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-sonnet-5")
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
assert factory.build_provider("anthropic", " ").model == "claude-sonnet-5"
def test_resolve_model_needs_no_api_key(monkeypatch):
"""Budzet promptu zalezy od okna kontekstu modelu — nie moze wymagac klucza.
Wczesniej liczenie budzetu szlo przez build_provider(), ktory bez klucza
rzuca bledem, wiec maksymalny kontekst" cicho spadal do wartosci zapasowej.
"""
_clear(monkeypatch)
provider, model = factory.resolve_model("anthropic", "claude-haiku-4-5")
assert (provider, model) == ("anthropic", "claude-haiku-4-5")
with pytest.raises(LLMError): # samo zbudowanie nadal wymaga klucza
factory.build_provider("anthropic", "claude-haiku-4-5")
def test_max_budget_differs_between_models(monkeypatch):
"""Sedno funkcji: wieksze okno = wiekszy budzet promptu."""
from app.llm.limits import prompt_token_budget
_clear(monkeypatch)
opus = prompt_token_budget(*factory.resolve_model("anthropic", "claude-opus-4-8"))
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
assert opus > haiku > local > 0
def test_turn_budget_counts_produced_not_requested(monkeypatch):
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
tokenow konczylo petle po jednej turze urwany fragment wracal jako calosc."""
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
_chat("Zakonczenie. KONIEC", "stop")]
def handler(request):
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
def test_generate_reports_progress_events(monkeypatch):
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
monkeypatch.setattr(httpx, "Client", _mock_client(
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
events = []
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
"p", 8000, on_event=events.append)
kinds = [e["type"] for e in events]
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
assert kinds[-1] == "generated"
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
+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)

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