Compare commits

...

28 Commits

Author SHA1 Message Date
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
89 changed files with 3625 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
+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
+74
View File
@@ -1 +1,75 @@
# 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.
+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()
+36
View File
@@ -0,0 +1,36 @@
"""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.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)
@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()
+41
View File
@@ -0,0 +1,41 @@
"""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).")
limit: int = Field(50, ge=1, le=50000)
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)})
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}")
+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 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.
+24
View File
@@ -0,0 +1,24 @@
# 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.
## Uruchomienie (tylko profil porównawczy / dev / CI)
```bash
pip install -r requirements.txt
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).
+65
View File
@@ -0,0 +1,65 @@
"""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,
}
DEFAULT_OBJECTS = list(_PLANETS)
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):
xx, _retflag = swe.calc_ut(jd, _PLANETS[name], _FLAGS)
lon, lat, _dist, lon_speed = xx[0], xx[1], xx[2], xx[3]
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"]
+43
View File
@@ -0,0 +1,43 @@
# 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). `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/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
+58
View File
@@ -0,0 +1,58 @@
"""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",
# 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
+37
View File
@@ -0,0 +1,37 @@
"""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
from typing import Any
import httpx
from app.config import settings
class DataClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.data_url).rstrip("/")
def 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)
r.raise_for_status()
return r.json()
def health(self) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health")
r.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()
+85
View File
@@ -0,0 +1,85 @@
"""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
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).
"""
out: list[dict] = []
n = len(positions)
for i in range(n):
for j in range(i + 1, n):
a, b = positions[i], positions[j]
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"}
+53
View File
@@ -0,0 +1,53 @@
"""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.base import EphemerisEngine
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
from app.engine.models import ChartMoment
def _fmt(name: str, lon: float) -> dict:
return {
"name": name,
"sign": SIGNS[sign_index(lon)],
"in_sign": in_sign(lon),
"decimal": round(norm360(lon), 6),
}
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> 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]}
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
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)
result["house_system"] = system
result["angles"] = {
"Asc": _fmt("Asc", asc),
"MC": _fmt("MC", mc),
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
"IC": _fmt("IC", norm360(mc + 180.0)),
}
result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
for i, c in enumerate(cusp_list)
]
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
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
+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
+64
View File
@@ -0,0 +1,64 @@
"""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)
DEFAULT_OBJECTS = [
"Sun", "Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
]
@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,
}
@@ -0,0 +1,62 @@
"""RemoteEngine — klient izolowanej usługi silnika (LOG-24 backend nr 2, LOG-27).
Realizuje ten sam interfejs co SkyfieldEngine, ale liczenie deleguje przez HTTP
do OSOBNEJ usługi `engine-swisseph` (AGPL). Dzięki granicy sieciowej kod AGPL
nigdy nie jest linkowany do permisywnego produktu — patrz services/engine-swisseph.
Używany tylko, gdy skonfigurowano ENGINE_SWISSEPH_URL (tryb porównawczy/dev/CI).
"""
from __future__ import annotations
import os
import httpx
from app.engine.base import EphemerisEngine
from app.engine.formats import norm360
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
class RemoteEngine(EphemerisEngine):
name = "swisseph"
def __init__(self, base_url: str | None = None, timeout: float = 15.0) -> None:
self.base_url = (base_url or os.getenv("ENGINE_SWISSEPH_URL", "")).rstrip("/")
self.timeout = timeout
def positions(
self, moment: ChartMoment, objects: list[str] | None = None
) -> list[ObjectPosition]:
if not self.base_url:
raise RuntimeError("ENGINE_SWISSEPH_URL nie ustawiony — silnik B niedostępny")
payload = {
"when_utc": moment.when_utc.isoformat(),
"lat": moment.lat,
"lon": moment.lon,
"objects": objects or DEFAULT_OBJECTS,
}
with httpx.Client(timeout=self.timeout) as client:
r = client.post(f"{self.base_url}/positions", json=payload)
r.raise_for_status()
rows = r.json()["positions"]
return [
ObjectPosition(
name=row["name"],
longitude=norm360(row["longitude"]),
latitude=row["latitude"],
speed=row["speed"],
retrograde=row["retrograde"],
)
for row in rows
]
def health(self) -> dict:
if not self.base_url:
return {"engine": self.name, "status": "disabled"}
try:
with httpx.Client(timeout=self.timeout) as client:
r = client.get(f"{self.base_url}/health")
r.raise_for_status()
return {"engine": self.name, "status": "ok", "remote": r.json()}
except httpx.HTTPError as e:
return {"engine": self.name, "status": "down", "error": str(e)}
@@ -0,0 +1,104 @@
"""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 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:
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}
+117
View File
@@ -0,0 +1,117 @@
"""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.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
from app.service import QueryService
app = FastAPI(title="astrololo · warstwa logiczna")
service = QueryService()
# --- 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
@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), aktywnym silnikiem."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
return build_chart(engine, moment, req.house_system)
@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}
@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)
+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)
+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"),
},
)
+195
View File
@@ -0,0 +1,195 @@
"""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",
}
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}
+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
+31
View File
@@ -0,0 +1,31 @@
"""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 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
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("") == ""
+90
View File
@@ -0,0 +1,90 @@
"""Testy aspektów (LOG-06) — czysta matematyka."""
from app.engine.aspects import 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_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)
+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
+20
View File
@@ -0,0 +1,20 @@
"""Testy punktacji siły faset (LOG-21)."""
from app.scoring import score_facet
def test_sign_and_house_base():
assert score_facet({"type": "sign"}) == 5.0
assert score_facet({"type": "house"}) == 5.0
def test_tighter_aspect_scores_higher():
tight = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 0.0, "allowed": 10.0})
loose = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 9.0, "allowed": 10.0})
assert tight > loose
assert tight == 12.0 # 6.0 * 1.0 * (1 + 1)
def test_conjunction_beats_sextile_at_same_orb():
conj = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 2.0, "allowed": 10.0})
sext = score_facet({"type": "aspect", "aspect": "sextile", "orb": 2.0, "allowed": 10.0})
assert conj > sext
@@ -0,0 +1,93 @@
"""Testy mostu obliczenia → sygnifikatory → wyszukiwanie (bez efemeryd/HTTP)."""
from app.engine.models import DEFAULT_OBJECTS
from app.significators import PLANET_ABBR, _ordinal, build_report
class FakeData:
def __init__(self, rows_by_value: dict) -> None:
self.rows_by_value = rows_by_value
def search(self, key, value, exact, limit, fields=None) -> dict:
rows = self.rows_by_value.get(value, [])
return {"provider": "fake", "total": len(rows), "rows": rows}
def test_sign_and_house_facets():
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
data = FakeData({"[Su": [
{"significator": "[Su in [Tau", "actioneffect": "efekt znak"}, # faseta znak
{"significator": "[Su in 11th H.", "actioneffect": "efekt dom"}, # faseta dom
{"significator": "[Su in [Vir", "actioneffect": "inny"}, # żadna
{"significator": "[Su in [Tau", "actioneffect": "nan"}, # szum
]})
item = build_report(positions, data)["objects"][0]
assert item["object"] == "Sun" and item["house"] == 11
facets = {f["type"]: f for f in item["facets"]}
assert facets["sign"]["count"] == 1
assert facets["sign"]["samples"][0]["effect"] == "efekt znak"
assert facets["sign"]["samples"][0]["expanded"] == "Sun in Taurus" # rozwinięcie skrótu
assert facets["house"]["count"] == 1
assert facets["house"]["token"] == "11th H."
assert facets["house"]["samples"][0]["effect"] == "efekt dom"
def test_no_house_facet_when_house_missing():
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx"}]
data = FakeData({"[Ma": [{"significator": "[Ma in [Sco", "actioneffect": "eff"}]})
types = [f["type"] for f in build_report(positions, data)["objects"][0]["facets"]]
assert "sign" in types and "house" not in types
def test_dedup_by_significator_and_effect():
positions = [{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11}]
data = FakeData({"[Mo": [
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"},
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"}, # duplikat (sig+opis)
{"significator": "[Mo in 11th H.", "actioneffect": "inny efekt"}, # ten sam sig, inny opis
]})
facets = {f["type"]: f for f in build_report(positions, data)["objects"][0]["facets"]}
assert facets["house"]["count"] == 2 # duplikat odsiany, różny opis zostaje
def test_aspect_facet():
positions = [
{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11},
{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11},
]
aspects = [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.0}]
data = FakeData({"[Su": [{"significator": "[Su [conj [Mo", "actioneffect": "złączeni"}]})
sun = build_report(positions, data, aspects=aspects)["objects"][0]
asp = [f for f in sun["facets"] if f["type"] == "aspect"]
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
def test_grouping_by_effect():
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
data = FakeData({"[Ma": [
{"significator": "[Ma in 5th H.", "actioneffect": "miscarriage"},
{"significator": "[Sa in 5th H.", "actioneffect": "miscarriage"}, # inny sig, ten sam opis
{"significator": "[Ma in 5th H.", "actioneffect": "fever"},
]})
house = [f for f in build_report(positions, data, group=True)["objects"][0]["facets"]
if f["type"] == "house"][0]
groups = {g["effect"]: g for g in house["groups"]}
assert groups["miscarriage"]["count"] == 2
assert len(groups["miscarriage"]["significators"]) == 2
assert "score" in house
def test_facets_carry_score():
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
data = FakeData({"[Su": [{"significator": "[Su in [Tau", "actioneffect": "e"}]})
facets = build_report(positions, data)["objects"][0]["facets"]
assert all("score" in f for f in facets)
def test_ordinal():
assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd"
assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th"
def test_planet_abbr_covers_all_default_objects():
for o in DEFAULT_OBJECTS:
assert o in PLANET_ABBR
@@ -0,0 +1,44 @@
"""Walidacja silnika własnego względem wyroczni (LOG-01 + LOG-25).
Porównujemy pozycje policzone Skyfieldem ze znanymi wartościami Swiss Ephemeris
(astro.com) dla horoskopu referencyjnego. To dokładnie mechanizm LOG-25 — tyle
że wyrocznią są tu zapisane wcześniej wartości referencyjne zamiast żywej usługi
swisseph. Tolerancja 2' (120") z zapasem na różnicę barycentrum/centrum planety.
"""
from app.engine.compare import compare_positions
from app.engine.models import ObjectPosition
# obiekt -> (długość absolutna w stopniach, retrogradacja) — Swiss Ephemeris / astro.com
REFERENCE = {
"Sun": (40.139167, False), # Tau 10°08'21"
"Moon": (30.551389, False), # Tau 0°33'05"
"Mercury": (27.383889, True), # Ari 27°23'02" Rx
"Venus": (27.676944, False), # Ari 27°40'37"
"Mars": (234.541111, True), # Sco 24°32'28" Rx
"Jupiter": (282.961667, True), # Cap 12°57'42" Rx
"Saturn": (223.310833, True), # Sco 13°18'39" Rx
"Uranus": (252.813333, True), # Sag 12°48'48" Rx
"Neptune": (271.220833, True), # Cap 1°13'15" Rx
"Pluto": (210.478889, True), # Sco 0°28'44" Rx
}
TOLERANCE_ARCSEC = 120.0
def _reference_positions():
return [
ObjectPosition(name, lon, 0.0, (-1.0 if retro else 1.0), retro)
for name, (lon, retro) in REFERENCE.items()
]
def test_positions_match_swisseph_reference(own_engine, reference_moment):
got = own_engine.positions(reference_moment)
report = compare_positions(got, _reference_positions(), lon_tol_arcsec=TOLERANCE_ARCSEC)
assert report.ok, report.summary()
def test_retrograde_flags_match(own_engine, reference_moment):
got = {p.name: p for p in own_engine.positions(reference_moment)}
for name, (_lon, retro) in REFERENCE.items():
assert got[name].retrograde == retro, f"{name}: kierunek niezgodny"
+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 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+30
View File
@@ -0,0 +1,30 @@
# Warstwa prezentacji (`presentation`)
Niezależna usługa serwująca stronę WWW (formularz + tabela wyników). **W dół**
przekazuje dane z formularza do warstwy logicznej i renderuje opracowane wyniki.
Brak logiki biznesowej i dostępu do danych.
## Trasy
- `GET /`**Horoskop**: formularz danych momentu (data, godzina, strefa, opcjonalnie lokalizacja)
- `POST /` — liczy pozycje obiektów (woła logic `/chart/positions`) i renderuje czytelną tabelę
- `GET /significators`, `POST /significators` — wyszukiwarka w bazach interpretacji (warstwa danych)
- `GET /health`
Strona „Horoskop" to widok do **ręcznego testowania**: podstawowe dane wejściowe →
to, co policzyła warstwa logiczna (znak, pozycja w znaku, absolutna, kierunek, prędkość).
Przycisk „Tu i teraz" uzupełnia bieżącą datę/godzinę i strefę przeglądarki.
## Zależności w dół
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej) i jej kontrakty `/chart/positions`
oraz `/api/query`. Nie wie nic o silniku ani bazach.
> Uwaga: pełne liczenie pozycji wymaga warstwy logicznej z silnikiem efemeryd
> (gałąź `feat/logic-engine`). Bez niego strona „Horoskop" pokaże czytelny komunikat.
## Uruchomienie
```bash
pip install -r requirements.txt
export LOGIC_URL=http://localhost:8001
uvicorn app.main:app --port 8000
# otwórz http://localhost:8000
```
@@ -0,0 +1,55 @@
"""Klient HTTP do warstwy logicznej.
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
"""
from __future__ import annotations
from typing import Any
import httpx
from app.config import settings
class LogicClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/api/query", json=payload)
r.raise_for_status()
return r.json()
def positions(
self,
when_utc_iso: str,
lat: float,
lon: float,
objects: list[str] | None = None,
house_system: str = "whole_sign",
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
"when_utc": when_utc_iso,
"lat": lat,
"lon": lon,
"objects": objects,
"house_system": house_system,
}
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/chart/positions", json=payload)
r.raise_for_status()
return r.json()
def report(
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
) -> dict[str, Any]:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/chart/report", json=payload)
r.raise_for_status()
return r.json()
+17
View File
@@ -0,0 +1,17 @@
"""Konfiguracja warstwy prezentacji.
Zna TYLKO adres warstwy logicznej (w dół). Nie wie nic o bazie/Excelu/SQL.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass
class Settings:
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
settings = Settings()
+135
View File
@@ -0,0 +1,135 @@
"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
„/significators".
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.clients.logic_client import LogicClient
app = FastAPI(title="astrololo · warstwa prezentacji")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny offset.
"""
local = datetime.fromisoformat(f"{date}T{time}")
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
label = utc.strftime("%Y-%m-%d %H:%M UTC")
return utc.isoformat(), label
def _logic_error(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
return (
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
)
return f"Warstwa logiczna niedostępna: {e}"
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def chart_form(request: Request):
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
@app.post("/", response_class=HTMLResponse)
def chart_compute(
request: Request,
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
):
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon, house_system=house_system
)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "chart.html", ctx)
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
@app.get("/significators", response_class=HTMLResponse)
def significators_form(request: Request):
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
@app.post("/significators", response_class=HTMLResponse)
def significators_search(
request: Request,
query: str = Form(...),
field: str = Form("name"),
exact: bool = Form(False),
limit: int = Form(25),
):
form = {"query": query, "field": field, "exact": exact, "limit": limit}
ctx: dict = {"form": form, "result": None, "error": None}
try:
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "significators.html", ctx)
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
@app.get("/interpret", response_class=HTMLResponse)
def interpret_form(request: Request):
return templates.TemplateResponse(request, "interpret.html", {"result": None, "form": {}})
@app.post("/interpret", response_class=HTMLResponse)
def interpret_run(
request: Request,
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
group: bool = Form(False),
):
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "interpret.html", ctx)
@app.get("/health")
def health() -> dict:
return {"status": "ok", "layer": "presentation"}
+43
View File
@@ -0,0 +1,43 @@
// „Tu i teraz": uzupełnia datę/godzinę/strefę z przeglądarki oraz — jeśli to
// możliwe — lokalizację (lat/lon).
//
// UWAGA: geolokalizacja przeglądarki działa tylko w "secure context"
// (https:// lub localhost). Na http://<ip> przeglądarka NIE pyta o zgodę,
// tylko po cichu odmawia — dlatego pokazujemy jawny komunikat w #geoNote.
document.addEventListener('DOMContentLoaded', function () {
const btn = document.getElementById('nowBtn');
if (!btn) return;
const note = document.getElementById('geoNote');
const say = msg => { if (note) note.textContent = msg; };
btn.addEventListener('click', function () {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
document.querySelector('input[name=date]').value =
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
if (!('geolocation' in navigator)) {
say('Ta przeglądarka nie udostępnia geolokalizacji — wpisz lat/lon ręcznie.');
return;
}
if (!window.isSecureContext) {
say('Lokalizacja z przeglądarki wymaga HTTPS lub localhost (otwarto przez http://) — wpisz lat/lon ręcznie.');
return;
}
say('Pobieram lokalizację…');
navigator.geolocation.getCurrentPosition(
function (pos) {
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
say('Lokalizacja pobrana ✓');
},
function (err) {
say('Nie udało się pobrać lokalizacji: ' + (err && err.message ? err.message : 'odmowa dostępu'));
},
{ timeout: 8000 }
);
});
});
@@ -0,0 +1,66 @@
:root {
--bg: #0f1020;
--panel: #1a1c33;
--ink: #e8e8f0;
--muted: #9aa0c0;
--accent: #8b7bf0;
--line: #2a2d4a;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; justify-content: center; padding: 3rem 1rem;
}
main { width: 100%; max-width: 880px; }
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
.topbar nav { display: flex; gap: .25rem; }
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
.topbar nav a:hover { color: var(--ink); }
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
input, select, button {
font: inherit; padding: .6rem .75rem; border-radius: 8px;
border: 1px solid var(--line); background: #12132a; color: var(--ink);
}
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
button:hover { filter: brightness(1.1); }
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
.opts input[type=number] { width: 5rem; }
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.empty { color: var(--muted); }
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
tr:last-child td { border-bottom: none; }
.muted { color: var(--muted); }
.small { font-size: .85rem; }
.nowrap { white-space: nowrap; }
.sig-item { margin-top: 1.25rem; }
.sig-head { padding: .5rem 0; }
.samples { margin-top: .3rem; }
.samples td { font-size: .92rem; vertical-align: top; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.facet { margin: .4rem 0 .6rem 1rem; }
.facet-head { color: var(--ink); font-size: .9rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main>
<header class="topbar">
<h1>astrololo</h1>
<nav>
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
</nav>
</header>
{% block content %}{% endblock %}
<footer class="foot">
prezentacja → logika → dane · widok testowy
</footer>
</main>
</body>
</html>
@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}Horoskop{% endblock %}
{% block nav_chart %}active{% endblock %}
{% block content %}
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
<form method="post" action="/">
<div class="grid">
<label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required>
</label>
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
</div>
<div class="grid">
<label>Szerokość (lat, + N)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
</label>
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
</select>
</label>
</div>
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit">Policz horoskop</button>
<span id="geoNote" class="muted small"></span>
</div>
</form>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="meta">
Silnik: <strong>{{ result.engine }}</strong> ·
obiektów: {{ result.positions | length }}
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %}
</div>
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<table>
<thead>
<tr>
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
</tr>
</thead>
<tbody>
{% for p in result.positions %}
<tr>
<td>{{ p.name }}</td>
<td>{{ p.sign }}</td>
<td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.aspects %}
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody>
{% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.cusps %}
<details class="loc">
<summary>Cusps domów ({{ result.house_system }})</summary>
<table>
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
<tbody>
{% for c in result.cusps %}
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% endif %}
<script src="/static/now.js"></script>
{% endblock %}
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Interpretacje{% endblock %}
{% block nav_interp %}active{% endblock %}
{% block content %}
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
<form method="post" action="/interpret">
<div class="grid">
<label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required>
</label>
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
<label>Szerokość (lat)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
</label>
<label>Długość (lon)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
</div>
<div class="opts">
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
</div>
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit">Szukaj interpretacji</button>
<span id="geoNote" class="muted small"></span>
</div>
</form>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% if result %}
{% if result.data_error %}
<div class="error">{{ result.data_error }}</div>
{% endif %}
<div class="meta">
Silnik: <strong>{{ result.engine }}</strong>
{% if result.provider %}· baza: {{ result.provider }}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %}
</div>
{% for o in result.objects %}
<div class="sig-item">
<div class="sig-head">
<strong>{{ o.object }}</strong> w <strong>{{ o.sign }}</strong>{% if o.house %}, {{ o.house }}. dom{% endif %}
<span class="{{ 'retro' if o.direction == 'Rx' else '' }}">{{ o.direction }}</span>
<span class="muted small">(planeta {{ o.planet_token }}: {{ o.planet_total }} rekordów)</span>
</div>
{% for f in o.facets %}
<div class="facet">
<div class="facet-head">{{ f.label }} — <strong>{{ f.count }}</strong> dopasowań
<span class="badge" title="siła (LOG-21)">siła {{ f.score }}</span>
<span class="muted small">[{{ f.token }}]</span></div>
{% if f.groups %}
<table class="samples">
<tbody>
{% for g in f.groups %}
<tr>
<td>{{ g.effect }}{% if g.count > 1 %} <span class="badge">×{{ g.count }}</span>{% endif %}</td>
<td class="sig small">{{ g.significators | join('; ') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elif f.samples %}
<table class="samples">
<tbody>
{% for s in f.samples %}
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
<script src="/static/now.js"></script>
{% endblock %}
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Sygnifikatory{% endblock %}
{% block nav_sig %}active{% endblock %}
{% block content %}
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
<form method="post" action="/significators">
<div class="row">
<input type="text" name="query" placeholder="Szukana fraza…"
value="{{ form.query or '' }}" autofocus required>
<select name="field">
{% for f in ["name", "id", "symbol", "category", "value"] %}
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
{% endfor %}
</select>
<button type="submit">Szukaj</button>
</div>
<div class="opts">
<label><input type="checkbox" name="exact" value="true"
{{ 'checked' if form.exact else '' }}> dokładne</label>
<label>limit
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
</label>
</div>
</form>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="meta">
Znaleziono <strong>{{ result.count }}</strong> ·
provider: {{ result.meta.data_provider }} ·
cache: {{ result.meta.data_cache }} ·
{{ result.meta.data_elapsed_ms }} ms
</div>
{% if result.results %}
<table>
<thead>
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for row in result.results %}
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Brak wyników.</p>
{% endif %}
{% endif %}
{% endblock %}
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
jinja2>=3.1
python-multipart>=0.0.20