Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5203ba9e76 | |||
| 64d1afc76d | |||
| 4da5f5fe7e | |||
| 4ef90b30bc | |||
| 73fd41b9d3 | |||
| 752f477a27 | |||
| 04b26afa6d | |||
| 2892b71be3 | |||
| 6f87b2b323 | |||
| a2aabd37a5 | |||
| de5d58958c | |||
| ab6fc0722c | |||
| 6ced2ddd22 | |||
| 79bce8ae90 | |||
| 85e9182f12 | |||
| c75f8377bf | |||
| 5234e86c95 | |||
| 0ac8df250c | |||
| 9323803cb4 | |||
| 678c3bad76 | |||
| 88a1ecd3c4 | |||
| b28c612bf7 | |||
| 2bda4e311d | |||
| 5d0d86cb90 | |||
| 9d5b65ddbd | |||
| cff0ac194c | |||
| 73f39e7df8 | |||
| 96d983b26a | |||
| c4a181b810 | |||
| 97ad21d2e4 | |||
| 2dbd3410e9 | |||
| ca458fd741 | |||
| b013831492 | |||
| d14a77360a | |||
| a8c3072e62 | |||
| 93932246f3 | |||
| 8d579de34a | |||
| db3d1e5117 | |||
| 4a86d34f5a | |||
| fb317172ff | |||
| f6323cac10 | |||
| 243d02d55b | |||
| 166b438f83 | |||
| 41d7ca491d | |||
| 6acd3546fb | |||
| 82809665ff | |||
| 0e74566a78 | |||
| cea6907f84 | |||
| dabbaef9dd | |||
| a81ae698a3 | |||
| eef67d37b5 | |||
| 94c3023d3a | |||
| e4f6a16c9c | |||
| 413c46b5dd | |||
| 340b3058e4 | |||
| 376a4bfded | |||
| 3195d9b003 | |||
| e69c0714b9 | |||
| 9bf297d461 | |||
| 561d95c7f4 | |||
| 4231f640b8 | |||
| f86ab55b29 | |||
| 21442b35b9 | |||
| 269c89152e | |||
| 16d35c16dc |
@@ -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
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: build-swisseph
|
||||||
|
|
||||||
|
# Osobny pipeline dla silnika B (Swiss Ephemeris, AGPL) — celowo ODDZIELONY od
|
||||||
|
# głównego build.yaml (data/logic/presentation). Buduje się tylko, gdy zmienia się
|
||||||
|
# sam silnik, i nie miesza obrazu AGPL do pipeline'u permisywnego produktu.
|
||||||
|
#
|
||||||
|
# Obraz konsumuje profil deployu `astrololo-swisseph` w repo `deploy`.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'services/engine-swisseph/**'
|
||||||
|
- '.gitea/workflows/build-swisseph.yaml'
|
||||||
|
workflow_dispatch: {} # ręczne odpalenie (bootstrap pierwszego obrazu)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||||
|
- name: Build & push engine-swisseph (AGPL, izolowany)
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::8}
|
||||||
|
IMG=gitea.czernobog.pl/gitea/astrololo-engine-swisseph
|
||||||
|
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312) — build jest
|
||||||
|
# zarazem realnym testem Dockerfile'a.
|
||||||
|
docker build -t $IMG:$TAG -t $IMG:latest ./services/engine-swisseph
|
||||||
|
docker push $IMG:$TAG
|
||||||
|
docker push $IMG:latest
|
||||||
|
echo "Zbudowano i wypchnięto: $IMG:$TAG (+ latest)"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||||
|
- name: Build & push (data, logic, presentation)
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::8}
|
||||||
|
for SVC in data logic presentation; do
|
||||||
|
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||||
|
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
||||||
|
done
|
||||||
|
echo "Tag: $TAG"
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
name: Testy
|
||||||
|
|
||||||
|
# Odpala się przy każdym pushu (dowolna gałąź) oraz dla pull requestów do master.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
logic-tests:
|
||||||
|
name: Testy warstwy logicznej (silnik)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Python 3.12 (jak w obrazach Dockera)
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: services/logic/requirements-dev.txt
|
||||||
|
|
||||||
|
# Jądro efemeryd JPL (de421.bsp, ~17 MB). Cache'ujemy je między runami.
|
||||||
|
- name: Cache jądra efemeryd
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: services/logic/.ephemeris
|
||||||
|
key: ephemeris-de421
|
||||||
|
|
||||||
|
# Pobieramy jawnie (a nie licząc na auto-pobranie przez Skyfield), żeby
|
||||||
|
# brak jądra był twardym błędem, a nie cichym pomijaniem testów.
|
||||||
|
- name: Pobierz jądro efemeryd (gdy brak w cache)
|
||||||
|
run: |
|
||||||
|
mkdir -p services/logic/.ephemeris
|
||||||
|
if [ ! -s services/logic/.ephemeris/de421.bsp ]; then
|
||||||
|
curl -fSL --retry 3 --max-time 300 \
|
||||||
|
-o services/logic/.ephemeris/de421.bsp \
|
||||||
|
https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp
|
||||||
|
fi
|
||||||
|
ls -lh services/logic/.ephemeris/de421.bsp
|
||||||
|
|
||||||
|
- name: Instalacja zależności
|
||||||
|
run: pip install -r services/logic/requirements-dev.txt
|
||||||
|
|
||||||
|
# CI=true (ustawiane przez GitHub) sprawia, że brak silnika = błąd,
|
||||||
|
# a nie pominięcie — patrz tests/conftest.py.
|
||||||
|
- name: Testy (pytest)
|
||||||
|
working-directory: services/logic
|
||||||
|
env:
|
||||||
|
PYTHONPATH: .
|
||||||
|
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
|
||||||
|
run: pytest tests -q -rs
|
||||||
|
|
||||||
|
presentation-tests:
|
||||||
|
name: Testy warstwy prezentacji (dostęp do baz)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: services/presentation/requirements-dev.txt
|
||||||
|
- name: Instalacja zależności
|
||||||
|
run: pip install -r services/presentation/requirements-dev.txt
|
||||||
|
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
|
||||||
|
# niż jego brak, bo daje złudzenie zabezpieczenia.
|
||||||
|
- name: Testy (pytest)
|
||||||
|
working-directory: services/presentation
|
||||||
|
env:
|
||||||
|
PYTHONPATH: .
|
||||||
|
run: pytest tests -q -rs
|
||||||
|
|
||||||
|
swisseph-image:
|
||||||
|
name: Build obrazu silnika B (swisseph)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312), więc ten
|
||||||
|
# build jest realnym testem Dockerfile'a — nie tylko pobraniem paczek.
|
||||||
|
- name: docker build
|
||||||
|
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
|
||||||
|
|
||||||
|
- name: Smoke test (health + pozycje)
|
||||||
|
run: |
|
||||||
|
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl -fsS http://localhost:8003/health
|
||||||
|
echo
|
||||||
|
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
|
||||||
|
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
|
||||||
|
curl -fsS -X POST http://localhost:8003/positions \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}'
|
||||||
|
echo
|
||||||
|
|
||||||
|
- name: Logi kontenera (gdy coś padło)
|
||||||
|
if: failure()
|
||||||
|
run: docker logs swe || true
|
||||||
|
|
||||||
|
compile-all:
|
||||||
|
name: Kontrola składni wszystkich warstw
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
# Sam kompilator — bez instalowania zależności warstw (w tym AGPL-owego
|
||||||
|
# silnika swisseph, który nie wchodzi do produktu).
|
||||||
|
- name: py_compile
|
||||||
|
run: python -m compileall -q services
|
||||||
+25
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -1 +1,76 @@
|
|||||||
# astrololo
|
# astrololo
|
||||||
|
|
||||||
|
Aplikacja w **modelu trójwarstwowym**, w pełni modułowa: trzy niezależne usługi,
|
||||||
|
każda komunikuje się wyłącznie z sąsiadem (nigdy „przez głowę”).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐ formularz (w dół) ┌──────────────────────┐ zapytanie (w dół) ┌──────────────────────┐
|
||||||
|
│ PREZENTACJA (:8000) │ ───────────────────▶ │ LOGICZNA (:8001) │ ───────────────────▶ │ BAZODANOWA (:8002) │
|
||||||
|
│ strona WWW + form │ ◀─────────────────── │ reguły biznesowe │ ◀─────────────────── │ wyszukiwanie danych │
|
||||||
|
└──────────────────────┘ wyniki (w górę) └──────────────────────┘ dane (w górę) └──────────────────────┘
|
||||||
|
HTML/UI pośrednik + logika Excel(+cache) ▸ SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Każda warstwa to osobny katalog, osobny `requirements.txt`, osobny `Dockerfile`
|
||||||
|
i osobne README. Komunikacja przez HTTP/JSON. Warstwa zna **tylko adres warstwy
|
||||||
|
bezpośrednio pod nią** — nic o jej wnętrzu.
|
||||||
|
|
||||||
|
| Warstwa | Katalog | Zna w dół | Zadanie |
|
||||||
|
|--------|---------|-----------|---------|
|
||||||
|
| Prezentacji | [`services/presentation`](services/presentation) | `LOGIC_URL` | serwuje stronę, przekazuje formularz, renderuje wyniki |
|
||||||
|
| Logiczna | [`services/logic`](services/logic) | `DATA_URL` | reguły biznesowe, tłumaczenie zapytań, opracowanie wyników |
|
||||||
|
| Bazodanowa | [`services/data`](services/data) | pliki Excela / SQL | **tylko** wyszukiwanie danych i podanie ich w górę |
|
||||||
|
|
||||||
|
## Szybki start (Docker)
|
||||||
|
```bash
|
||||||
|
make sample # przykładowe pliki .xlsx do warstwy bazodanowej
|
||||||
|
make up # zbuduj i uruchom 3 warstwy
|
||||||
|
# otwórz http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Szybki start (lokalnie, bez Dockera — zalecane)
|
||||||
|
```bash
|
||||||
|
python -m venv .env && source .env/bin/activate # venv (jednorazowo)
|
||||||
|
make install # zależności WSZYSTKICH warstw
|
||||||
|
make sample # opcjonalnie: dane przykładowe
|
||||||
|
```
|
||||||
|
Potem w 3 osobnych terminalach (w każdym `source .env/bin/activate`):
|
||||||
|
```bash
|
||||||
|
make dev-data # terminal 1 -> :8002
|
||||||
|
make dev-logic # terminal 2 -> :8001
|
||||||
|
make dev-presentation # terminal 3 -> :8000 -> http://localhost:8000
|
||||||
|
```
|
||||||
|
> `make install` instaluje zależności wszystkich trzech warstw do aktywnego venv.
|
||||||
|
> Testy silnika: `make test`. Wyczyszczenie cache: `make clean-cache`.
|
||||||
|
|
||||||
|
## Modułowość — dowód
|
||||||
|
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
|
||||||
|
- Wymień bazę (Excel → SQL) → prezentacja i logika bez zmian (patrz niżej).
|
||||||
|
- Każdą warstwę da się uruchomić, testować i wdrażać osobno.
|
||||||
|
|
||||||
|
## Wydajność warstwy Excela — cache 4-poziomowy
|
||||||
|
Dziś dane to setki dużych `.xlsx`, przeszukiwanych po **wykrytym nagłówku** i
|
||||||
|
**układzie kolumn**. To kosztowne, więc warstwa bazodanowa ma cache (szczegóły:
|
||||||
|
[`services/data/README.md`](services/data/README.md)):
|
||||||
|
|
||||||
|
1. **Schemat (L1, SQLite)** — wykryty nagłówek + mapowanie kolumn zapisane raz na wersję pliku.
|
||||||
|
2. **Dane (L2, Parquet)** — znormalizowany arkusz; kolejne odczyty 10–100× szybsze niż `.xlsx`.
|
||||||
|
3. **Zapytania (L3, in-memory TTL/LRU)** — powtarzalne wyszukiwania natychmiast (łatwo podmienić na Redis).
|
||||||
|
4. **Odwrócony indeks (L4, SQLite)** — `wartość → plik`; otwieramy tylko trafione pliki zamiast skanu setek.
|
||||||
|
|
||||||
|
Unieważnianie automatyczne: klucz cache = **odcisk pliku** (`mtime+rozmiar`,
|
||||||
|
opcjonalnie `sha256`). Zmiana pliku → przebudowa tylko jego wpisów.
|
||||||
|
|
||||||
|
## Droga na przyszłość — migracja do SQL
|
||||||
|
Warstwa bazodanowa ukrywa źródło za interfejsem `DataProvider` (wzorzec
|
||||||
|
Repository). Migracja:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make migrate # ETL: tym samym loaderem Excel -> tabela 'records' + indeksy
|
||||||
|
export DATA_PROVIDER=sql # przełącz całą warstwę
|
||||||
|
```
|
||||||
|
|
||||||
|
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
|
||||||
|
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
||||||
|
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
||||||
|
# updater test Mon 20 Jul 2026 19:52:08 CEST
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Architektura dwóch silników (warstwa logiczna)
|
||||||
|
|
||||||
|
Dokument roboczy. Decyzja: warstwa logiczna ma **dwie wymienne implementacje silnika obliczeń** — jedną **własną/permisywną** (ścieżka A) i jedną **opartą o AGPL** (ścieżka C, Swiss Ephemeris) — żeby od pierwszego dnia mieć **wbudowany framework porównań i walidacji**. Warstwy **prezentacji i danych pozostają wspólne i pojedyncze**; rozdwaja się wyłącznie silnik wewnątrz warstwy logicznej.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Schemat
|
||||||
|
|
||||||
|
```
|
||||||
|
(jedna) (jedna, ale 2 silniki) (jedna)
|
||||||
|
┌──────────────────────┐ ┌─────────────────────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ PREZENTACJA │ ─────▶ │ LOGIKA │ ─────▶ │ DANE │
|
||||||
|
│ (bez zmian) │ ◀───── │ EngineProvider (interfejs) │ ◀───── │ (bez zmian) │
|
||||||
|
└──────────────────────┘ │ ├── EngineA: własny (permisywny)│ └──────────────────────┘
|
||||||
|
│ │ Skyfield/Moshier — in-proc │
|
||||||
|
│ └── EngineB: swisseph (AGPL) │
|
||||||
|
│ ⇢ OSOBNY proces/usługa │
|
||||||
|
│ Komparator (dual-run + raport) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
⇢ (granica sieciowa/IPC)
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ engine-swisseph (AGPL, OSOBNO) │
|
||||||
|
│ pyswisseph / Swiss Ephemeris │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Prezentacja i dane „widzą" tylko interfejs `EngineProvider` — **nie wiedzą, który silnik liczy** (engine‑agnostic). To ta sama zasada modułowości co `DataProvider` w warstwie danych.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Po co dwa silniki
|
||||||
|
|
||||||
|
1. **Wyrocznia walidacyjna od dnia zero.** Każdy wynik EngineA możemy natychmiast porównać z EngineB (Swiss Ephemeris = de‑facto standard). Bezcenne przy budowie własnego silnika od zera (primary directions, domy, węzły/Lilith).
|
||||||
|
2. **Framework porównań.** Tryb „policz oboma i pokaż różnice" — do testów regresyjnych (CI) i do podglądu side‑by‑side w UI.
|
||||||
|
3. **Bezpieczeństwo decyzji.** Gdyby własny silnik gdzieś odstawał, widać to od razu, a nie po miesiącach.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Izolacja licencyjna (warunek konieczny) ⚠️
|
||||||
|
|
||||||
|
EngineB jest **AGPL**, więc nie może być wlinkowany w permisywny produkt. Rozwiązanie zgodne z naszą architekturą (osobne usługi jak warstwa danych):
|
||||||
|
|
||||||
|
- **EngineB = osobny proces/usługa `engine-swisseph`**, wołany przez **granicę sieciową/IPC** (HTTP). Brak linkowania = brak „zarażenia" copyleftem reszty.
|
||||||
|
- **Produkt zamknięty (dystrybucja/hosting):** prezentacja + logika(**EngineA**) + dane. **Bez** EngineB. Zero AGPL.
|
||||||
|
- **Profil porównawczy/dev/CI:** dodatkowo uruchamiamy usługę `engine-swisseph` (AGPL, sama w sobie zgodna z AGPL — to w zasadzie cienkie API na pyswisseph). Używana wewnętrznie do porównań, nie wystawiana użytkownikom produktu.
|
||||||
|
- **Konfiguracja** decyduje, czy EngineB jest w ogóle dostępny (`ENGINE_B_URL` ustawione lub nie).
|
||||||
|
|
||||||
|
Efekt: pełny framework porównań **bez** narażania kodu i danych produktu na obowiązki AGPL (patrz `przeglad-bibliotek-i-licencji.md` oraz analiza zakresu AGPL względem baz danych).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Jak działa porównanie (dual‑run)
|
||||||
|
|
||||||
|
1. To samo wejście (dane horoskopu + ustawienia) trafia do EngineA i EngineB.
|
||||||
|
2. Komparator zestawia wyniki: pozycje obiektów, cusps domów, aspekty, daty technik.
|
||||||
|
3. Różnice liczone z **progami tolerancji per wielkość** (np. długość ekliptyczna ≤ 1″, cusp ≤ 1″, data zdarzenia ≤ 1 min); przekroczenia oflagowane.
|
||||||
|
4. Raport: (a) jako asercje w **CI** (regresja), (b) jako **podgląd side‑by‑side** w prezentacji na żądanie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Wpływ na warstwy i mapowanie na wymagania
|
||||||
|
|
||||||
|
| Warstwa | Zmiana |
|
||||||
|
|---|---|
|
||||||
|
| Prezentacja | brak (opcjonalnie: widok porównawczy side‑by‑side) |
|
||||||
|
| Dane | brak |
|
||||||
|
| **Logika** | `EngineProvider` + 2 backendy (EngineA in‑proc, EngineB jako klient usługi AGPL) + komparator |
|
||||||
|
| **Nowa usługa** | `engine-swisseph` (AGPL, osobno, opcjonalna) |
|
||||||
|
|
||||||
|
Wymagania (arkusz „Warstwa logiczna"):
|
||||||
|
- **LOG‑24** — interfejs `EngineProvider` z **dwoma** backendami (zrewidowane).
|
||||||
|
- **LOG‑25** — framework walidacyjno‑porównawczy (zrewidowane).
|
||||||
|
- **LOG‑26** — tryb dual‑run + raport różnic (nowe).
|
||||||
|
- **LOG‑27** — izolacja licencyjna silnika AGPL jako osobnej usługi (nowe).
|
||||||
|
- **LOG‑28** — kontrakt parzystości silników: identyczny interfejs, te same testy (nowe).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Implikacje wdrożeniowe
|
||||||
|
|
||||||
|
- **Dwa profile `docker-compose`:** `proprietary` (prezentacja+logika+dane, EngineA) i `comparison` (dodaje `engine-swisseph`).
|
||||||
|
- **Testy kontraktowe** uruchamiane na obu silnikach (ten sam zestaw → gwarancja parzystości interfejsu).
|
||||||
|
- **Decyzja A vs B na produkcji** pozostaje po stronie A; B nigdy nie trafia do dystrybucji zamkniętej — służy jako wzorzec i narzędzie QA.
|
||||||
Binary file not shown.
@@ -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"]
|
||||||
@@ -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 | 10–100× 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
|
||||||
|
```
|
||||||
+22
@@ -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
@@ -0,0 +1,40 @@
|
|||||||
|
"""Cache danych: znormalizowany arkusz zapisany jako Parquet.
|
||||||
|
|
||||||
|
POZIOM 2 cache — największy zysk wydajności. Parsowanie .xlsx jest wolne
|
||||||
|
(dziesiątki–setki ms na duży plik). Po pierwszym wczytaniu zapisujemy
|
||||||
|
znormalizowaną ramkę jako Parquet (kolumnowy, kompresowany), kluczowaną odciskiem
|
||||||
|
pliku. Kolejne odczyty ładują Parquet — zwykle 10–100x 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)
|
||||||
Vendored
+68
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Warstwa BAZODANOWA — usługa HTTP.
|
||||||
|
|
||||||
|
Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszukać
|
||||||
|
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
|
||||||
|
ani prezentacji.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||||
|
from app.providers.factory import build_provider
|
||||||
|
|
||||||
|
provider = build_provider(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
provider.warmup() # zbuduj/odśwież indeks i cache przy starcie
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
||||||
|
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/search", response_model=SearchResult)
|
||||||
|
def search(query: SearchQuery) -> SearchResult:
|
||||||
|
return provider.search(query)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthInfo)
|
||||||
|
def health() -> HealthInfo:
|
||||||
|
return provider.health()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Kontrakt danych warstwy bazodanowej.
|
||||||
|
|
||||||
|
Te modele są JEDYNYM publicznym interfejsem tej warstwy. Warstwa logiczna zna
|
||||||
|
wyłącznie te kształty (poprzez HTTP/JSON) — nie wie nic o Excelu, cache ani SQL.
|
||||||
|
Dzięki temu można podmienić implementację (Excel -> SQL) bez zmiany pozostałych
|
||||||
|
warstw.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SearchQuery(BaseModel):
|
||||||
|
"""Znormalizowane zapytanie wyszukiwania przychodzące z warstwy logicznej."""
|
||||||
|
|
||||||
|
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||||
|
value: str = Field(..., description="Szukana wartość.")
|
||||||
|
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
||||||
|
# Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki
|
||||||
|
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
|
||||||
|
# realnie potrzebuje build_report na jeden obiekt.
|
||||||
|
limit: int = Field(50, ge=1, le=5000)
|
||||||
|
fields: list[str] | None = Field(
|
||||||
|
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResult(BaseModel):
|
||||||
|
"""Wynik wyszukiwania zwracany w górę do warstwy logicznej."""
|
||||||
|
|
||||||
|
rows: list[dict[str, Any]]
|
||||||
|
total: int
|
||||||
|
elapsed_ms: float
|
||||||
|
cache: str = Field("miss", description="hit/miss/partial — skąd pochodzą dane.")
|
||||||
|
provider: str = Field(..., description="Nazwa aktywnej implementacji, np. 'excel' lub 'sql'.")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthInfo(BaseModel):
|
||||||
|
status: str = "ok"
|
||||||
|
provider: str
|
||||||
|
indexed_files: int = 0
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -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)
|
||||||
@@ -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)})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Uwierzytelnianie międzywarstwowe (LOG-32).
|
||||||
|
|
||||||
|
Warstwa danych oddaje SUROWE wiersze baz — to najbardziej wrażliwy punkt całego
|
||||||
|
systemu. Bez tego kontrolera wystarczyłoby uderzyć w nią bezpośrednio, z pominięciem
|
||||||
|
i logiki, i logowania w UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
|
||||||
|
musi go przynieść w nagłówku X-Astrololo-Token.
|
||||||
|
|
||||||
|
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
|
||||||
|
starcie leci ostrzeżenie.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.security")
|
||||||
|
|
||||||
|
HEADER = "X-Astrololo-Token"
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
def token() -> str:
|
||||||
|
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
|
||||||
|
return os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(token())
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, layer: str) -> None:
|
||||||
|
if not enabled():
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
|
||||||
|
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _guard(request: Request, call_next):
|
||||||
|
if request.url.path in PUBLIC_PATHS or not enabled():
|
||||||
|
return await call_next(request)
|
||||||
|
got = request.headers.get(HEADER, "")
|
||||||
|
if not secrets.compare_digest(got, token()):
|
||||||
|
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
|
||||||
|
status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Build wieloetapowy — bo `pyswisseph` to rozszerzenie C bez gotowych wheeli.
|
||||||
|
#
|
||||||
|
# Na PyPI (2.10.3.2) wheels kończą się na cp311 i obejmują wyłącznie i686/x86_64.
|
||||||
|
# Dla Pythona 3.12 oraz dla arm64 pip ZAWSZE kompiluje ze źródeł, a `-slim` nie ma
|
||||||
|
# kompilatora — dlatego jednoetapowy build tu padał. Kompilujemy w etapie builder,
|
||||||
|
# a do obrazu finalnego wchodzi już tylko gotowy wheel (bez toolchaina).
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
|
||||||
|
&& rm -rf /wheels
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Sanity check na etapie budowania: brak działającego swissepha ma wywalić build,
|
||||||
|
# a nie dopiero pierwszy request.
|
||||||
|
RUN python -c "import swisseph as swe; swe.set_ephe_path(None); print('swisseph OK', swe.version)"
|
||||||
|
|
||||||
|
EXPOSE 8003
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# engine-swisseph (silnik B — AGPL, izolowany)
|
||||||
|
|
||||||
|
Osobna, **opcjonalna** usługa będąca drugim silnikiem efemeryd (LOG‑27). Liczy
|
||||||
|
pozycje przez **pyswisseph / Swiss Ephemeris** i służy jako **wyrocznia
|
||||||
|
walidacyjna / tryb porównawczy** (LOG‑25/26) dla naszego silnika własnego.
|
||||||
|
|
||||||
|
## ⚠️ Licencja
|
||||||
|
Ten komponent jest **AGPL‑3.0** (bo linkuje Swiss Ephemeris) — patrz [LICENSE](LICENSE).
|
||||||
|
Jest **wydzielony jako osobny proces** i wołany przez HTTP, więc nie „zaraża"
|
||||||
|
permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.**
|
||||||
|
|
||||||
|
## API
|
||||||
|
- `POST /positions` → `{when_utc, lat, lon, objects?}` → pozycje (ten sam kształt co silnik własny)
|
||||||
|
- `GET /health`
|
||||||
|
|
||||||
|
Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji.
|
||||||
|
|
||||||
|
## Build obrazu
|
||||||
|
```bash
|
||||||
|
docker compose --profile comparison build engine-swisseph
|
||||||
|
```
|
||||||
|
Dockerfile jest **wieloetapowy** i to nie jest ozdobnik: `pyswisseph` to rozszerzenie
|
||||||
|
C, a na PyPI (2.10.3.2) gotowe wheels kończą się na **cp311** i obejmują wyłącznie
|
||||||
|
**i686/x86_64**. Dla Pythona 3.12 oraz dla arm64 pip musi kompilować ze źródeł, więc
|
||||||
|
sam `python:3.12-slim` (bez kompilatora) build wywracał. Kompilacja idzie w etapie
|
||||||
|
`builder` (`build-essential`), a do obrazu finalnego trafia już tylko gotowy wheel —
|
||||||
|
runtime zostaje czysty i mały. Pierwszy build trwa ~1–2 min, kolejne idą z cache warstw.
|
||||||
|
|
||||||
|
Build kończy się sanity-checkiem (`import swisseph`), żeby niedziałający silnik
|
||||||
|
wykrzaczył build, a nie dopiero pierwszy request.
|
||||||
|
|
||||||
|
## Uruchomienie (tylko profil porównawczy / dev / CI)
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej)
|
||||||
|
uvicorn app.main:app --port 8003
|
||||||
|
```
|
||||||
|
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
|
||||||
|
aby włączyć silnik B (tryb dual-run i testy kontraktowe silnika B).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""engine-swisseph — IZOLOWANA usługa silnika B (AGPL).
|
||||||
|
|
||||||
|
UWAGA LICENCYJNA: ta usługa linkuje pyswisseph / Swiss Ephemeris, więc jest
|
||||||
|
objęta **AGPL-3.0** i jest licencjonowana osobno (patrz ./LICENSE). Jest celowo
|
||||||
|
wydzielona jako osobny proces i wołana przez HTTP — dzięki temu permisywny
|
||||||
|
produkt (prezentacja + logika z silnikiem własnym + dane) NIE jest linkowany z
|
||||||
|
kodem AGPL i nie podlega jego obowiązkom (LOG-27).
|
||||||
|
|
||||||
|
Rola: wyrocznia walidacyjna / tryb porównawczy (LOG-25/26). Nie wchodzi do
|
||||||
|
dystrybucji zamkniętej — uruchamiana tylko w profilu porównawczym/dev/CI.
|
||||||
|
|
||||||
|
Udostępnia ten sam kontrakt co RemoteEngine po stronie warstwy logicznej.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import swisseph as swe
|
||||||
|
|
||||||
|
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
|
||||||
|
|
||||||
|
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
|
||||||
|
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
||||||
|
|
||||||
|
_PLANETS = {
|
||||||
|
"Sun": swe.SUN, "Moon": swe.MOON, "Mercury": swe.MERCURY, "Venus": swe.VENUS,
|
||||||
|
"Mars": swe.MARS, "Jupiter": swe.JUPITER, "Saturn": swe.SATURN,
|
||||||
|
"Uranus": swe.URANUS, "Neptune": swe.NEPTUNE, "Pluto": swe.PLUTO,
|
||||||
|
# punkty wirtualne — mean, jak w silniku własnym (parzystość LOG-28)
|
||||||
|
"North Node": swe.MEAN_NODE, "Lilith": swe.MEAN_APOG,
|
||||||
|
# "South Node" obsługiwany pochodnie w /positions: NN + 180°
|
||||||
|
}
|
||||||
|
DEFAULT_OBJECTS = [
|
||||||
|
"Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
|
||||||
|
"Uranus", "Neptune", "Pluto", "North Node", "South Node", "Lilith",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PositionsRequest(BaseModel):
|
||||||
|
when_utc: datetime
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
objects: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/positions")
|
||||||
|
def positions(req: PositionsRequest) -> dict:
|
||||||
|
d = req.when_utc
|
||||||
|
ut_hours = d.hour + d.minute / 60.0 + d.second / 3600.0
|
||||||
|
jd = swe.julday(d.year, d.month, d.day, ut_hours) # czas uniwersalny
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for name in (req.objects or DEFAULT_OBJECTS):
|
||||||
|
lookup = "North Node" if name == "South Node" else name
|
||||||
|
xx, _retflag = swe.calc_ut(jd, _PLANETS[lookup], _FLAGS)
|
||||||
|
lon, lat, _dist, lon_speed = xx[0], xx[1], xx[2], xx[3]
|
||||||
|
if name == "South Node":
|
||||||
|
lon, lat = lon + 180.0, -lat
|
||||||
|
rows.append({
|
||||||
|
"name": name,
|
||||||
|
"longitude": lon % 360.0,
|
||||||
|
"latitude": lat,
|
||||||
|
"speed": lon_speed,
|
||||||
|
"retrograde": lon_speed < 0,
|
||||||
|
})
|
||||||
|
return {"engine": "swisseph", "positions": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# UWAGA: pyswisseph (Swiss Ephemeris) jest na licencji AGPL-3.0 — dlatego ta
|
||||||
|
# usługa jest wydzielona i licencjonowana osobno (patrz LICENSE). Nie instaluj
|
||||||
|
# tego w obrazie permisywnego produktu.
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
pyswisseph>=2.10
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Warstwa logiczna (`logic`)
|
||||||
|
|
||||||
|
Niezależna usługa pośrednicząca. **W górę** udostępnia API dla prezentacji,
|
||||||
|
**w dół** woła warstwę bazodanową. Tu żyją reguły biznesowe — nie w prezentacji
|
||||||
|
i nie w bazie.
|
||||||
|
|
||||||
|
## API
|
||||||
|
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
||||||
|
- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Zwraca też sektę i 7 Lots hermetycznych z domami (LOG-08). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
|
||||||
|
- `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
|
||||||
|
- `POST /chart/profections` → `{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10)
|
||||||
|
- `POST /chart/return` → `{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
|
||||||
|
- `POST /chart/firdaria` → `{when_utc, lat, lon}` → Firdaria: sekta (dzień/noc), okresy główne i podokresy time-lordów (LOG-11)
|
||||||
|
- `POST /chart/timeline` → `{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc + Firdaria, posortowane (technique | significator | start | exact | end); z interpret=true dopina interpretacje z bazy do dat (LOG-14, 1B->2B)
|
||||||
|
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
||||||
|
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||||
|
|
||||||
|
## Silnik efemeryd (LOG-24, „wymienny silnik liczący")
|
||||||
|
W `app/engine/` żyje pluggable silnik za interfejsem `EphemerisEngine`:
|
||||||
|
- **`SkyfieldEngine`** — własny, permisywny (Skyfield MIT + dane JPL public domain). Domyślny.
|
||||||
|
- **`RemoteEngine`** — klient OSOBNEJ, izolowanej usługi `engine-swisseph` (AGPL), używany tylko w trybie porównawczym.
|
||||||
|
|
||||||
|
Wybór: `EPHEMERIS_ENGINE=own|swisseph`. Silnik B włącza się przez `ENGINE_SWISSEPH_URL`.
|
||||||
|
|
||||||
|
Walidacja (LOG-25/28): `app/engine/compare.py` zestawia oba silniki z progiem tolerancji;
|
||||||
|
ten sam kontrakt parzystości obowiązuje każdy silnik. Nasz `SkyfieldEngine` zgadza się
|
||||||
|
ze Swiss Ephemeris **co do ~1″** na horoskopie referencyjnym (patrz `tests/`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
PYTHONPATH=. pytest tests -q # silnik B pomijany, jeśli ENGINE_SWISSEPH_URL nieustawiony
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zależności w dół
|
||||||
|
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
||||||
|
Nie wie, czy pod spodem jest Excel czy SQL.
|
||||||
|
|
||||||
|
## Uruchomienie
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
export DATA_URL=http://localhost:8002
|
||||||
|
uvicorn app.main:app --port 8001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gdzie rozbudowywać domenę
|
||||||
|
`service.py` → `QueryService.handle()`: walidacja wejścia, tłumaczenie zapytania,
|
||||||
|
obliczenia i wzbogacanie wyników.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Rozwijanie skrótów sygnifikatorów do postaci czytelnej (na bazie SIGNIFICATORS KEY).
|
||||||
|
|
||||||
|
W bazie zapis jest skrótowy z prefiksem `[` (np. `[Su in [Tau`, `[Sa [conj [Su in 6th H.`).
|
||||||
|
Ten moduł zamienia go na tekst czytelny: „Sun in Taurus", „Saturn conjunction Sun
|
||||||
|
in 6th house". Słownik pochodzi z pliku SIGNIFICATORS KEY (mały, stabilny — wpięty
|
||||||
|
jako built-in; można rozszerzać).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# skrót (bez nawiasu) -> pełna nazwa
|
||||||
|
ABBREVIATIONS: dict[str, str] = {
|
||||||
|
# znaki zodiaku
|
||||||
|
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer",
|
||||||
|
"Leo": "Leo", "Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio",
|
||||||
|
"Sag": "Sagittarius", "Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||||
|
# planety klasyczne + światła
|
||||||
|
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus",
|
||||||
|
"Ma": "Mars", "Ju": "Jupiter", "Sa": "Saturn",
|
||||||
|
# planety nowożytne
|
||||||
|
"Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||||
|
# węzły i punkty
|
||||||
|
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||||
|
# osie
|
||||||
|
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
|
||||||
|
# Lots (punkty arabskie)
|
||||||
|
"PF": "Part of Fortune", "Fortune": "Part of Fortune", "Spirit": "Lot of Spirit",
|
||||||
|
# aspekty
|
||||||
|
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
|
||||||
|
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
|
||||||
|
"sesquisq": "sesquisquare", "quincunx": "quincunx", "asp": "aspect",
|
||||||
|
# domy jako tokeny [h1..[h12
|
||||||
|
**{f"h{i}": f"{i}th house" for i in range(1, 13)},
|
||||||
|
# ruch
|
||||||
|
"Rx": "retrograde", "R": "retrograde",
|
||||||
|
}
|
||||||
|
# poprawki nieregularnych liczebników domów
|
||||||
|
ABBREVIATIONS.update({"h1": "1st house", "h2": "2nd house", "h3": "3rd house"})
|
||||||
|
|
||||||
|
# rozwinięcia słów spoza składni `[`
|
||||||
|
_WORDS = {
|
||||||
|
"affl.": "afflicted",
|
||||||
|
"P. Dec.": "parallel of declination",
|
||||||
|
"espec.": "especially",
|
||||||
|
}
|
||||||
|
|
||||||
|
_TOKEN = re.compile(r"\[([A-Za-z]+)")
|
||||||
|
_HOUSE = re.compile(r"(\d+(?:st|nd|rd|th))\s*H\.", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def expand(text: str) -> str:
|
||||||
|
"""Zamienia skróty na pełne nazwy; nieznane tokeny zostawia bez nawiasu."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
out = _TOKEN.sub(lambda m: ABBREVIATIONS.get(m.group(1), m.group(1)), text)
|
||||||
|
out = _HOUSE.sub(lambda m: f"{m.group(1)} house", out)
|
||||||
|
for k, v in _WORDS.items():
|
||||||
|
out = out.replace(k, v)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Klient HTTP do warstwy bazodanowej.
|
||||||
|
|
||||||
|
Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
|
||||||
|
(Excel→SQL), tutaj nie zmienia się NIC — kontrakt /search jest stały.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> dict[str, str]:
|
||||||
|
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
|
||||||
|
token = os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
return {"X-Astrololo-Token": token} if token else {}
|
||||||
|
|
||||||
|
|
||||||
|
class DataClient:
|
||||||
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
|
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
exact: bool,
|
||||||
|
limit: int,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
||||||
|
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||||
|
r = client.post(f"{self.base_url}/search", json=payload, headers=_auth_headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def health(self) -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Aspekty — kąty między obiektami (LOG-06, wersja: aspekty główne).
|
||||||
|
|
||||||
|
Czysta matematyka na policzonych długościach ekliptycznych. Dla każdej pary
|
||||||
|
obiektów sprawdzamy, czy ich separacja kątowa mieści się w orbie któregoś z
|
||||||
|
aspektów głównych. Applying/separating (aplikacja/separacja) — na później.
|
||||||
|
|
||||||
|
Tokeny bazy (z SIGNIFICATORS KEY): [conj, [sex, [sq, [tri, [opp.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
MAJOR = {
|
||||||
|
"conjunction": 0.0,
|
||||||
|
"sextile": 60.0,
|
||||||
|
"square": 90.0,
|
||||||
|
"trine": 120.0,
|
||||||
|
"opposition": 180.0,
|
||||||
|
}
|
||||||
|
DB_TOKEN = {
|
||||||
|
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
|
||||||
|
"trine": "[tri", "opposition": "[opp",
|
||||||
|
}
|
||||||
|
PL_NAME = {
|
||||||
|
"conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura",
|
||||||
|
"trine": "trygon", "opposition": "opozycja",
|
||||||
|
}
|
||||||
|
LUMINARIES = {"Sun", "Moon"}
|
||||||
|
DEFAULT_ORB = 8.0
|
||||||
|
LUMINARY_BONUS = 2.0
|
||||||
|
|
||||||
|
# Pary sztywno powiązane definicyjnie — kąt między nimi wynika z samej definicji
|
||||||
|
# punktu, nie z układu nieba (SN = NN + 180°). Aspekt taki zawsze wychodzi
|
||||||
|
# dokładny (orb 0,00°) i nie niesie żadnej informacji astrologicznej, więc
|
||||||
|
# wycinamy go z wyników: zaśmieca listę w UI i zjada budżet promptu do LLM.
|
||||||
|
RIGID_PAIRS = frozenset({
|
||||||
|
frozenset({"North Node", "South Node"}),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rigid(name_a: str, name_b: str) -> bool:
|
||||||
|
return frozenset({name_a, name_b}) in RIGID_PAIRS
|
||||||
|
|
||||||
|
|
||||||
|
def separation(a: float, b: float) -> float:
|
||||||
|
"""Najmniejsza separacja kątowa [0,180]."""
|
||||||
|
d = abs(a - b) % 360.0
|
||||||
|
return min(d, 360.0 - d)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None:
|
||||||
|
"""Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)?
|
||||||
|
|
||||||
|
Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu
|
||||||
|
(pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying.
|
||||||
|
dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności.
|
||||||
|
Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć).
|
||||||
|
"""
|
||||||
|
if sa is None or sb is None:
|
||||||
|
return None
|
||||||
|
dev_now = abs(separation(la, lb) - angle)
|
||||||
|
dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle)
|
||||||
|
return dev_next < dev_now
|
||||||
|
|
||||||
|
|
||||||
|
def find_aspects(
|
||||||
|
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
|
) -> list[dict]:
|
||||||
|
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
|
||||||
|
|
||||||
|
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
|
||||||
|
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
|
||||||
|
|
||||||
|
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
n = len(positions)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
a, b = positions[i], positions[j]
|
||||||
|
if _is_rigid(a["name"], b["name"]):
|
||||||
|
continue
|
||||||
|
la, lb = a.get("decimal"), b.get("decimal")
|
||||||
|
if la is None or lb is None:
|
||||||
|
continue
|
||||||
|
sep = separation(float(la), float(lb))
|
||||||
|
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||||
|
for asp, angle in MAJOR.items():
|
||||||
|
dev = abs(sep - angle)
|
||||||
|
if dev <= allowed:
|
||||||
|
applying = _is_applying(
|
||||||
|
float(la), float(lb), a.get("speed"), b.get("speed"), angle
|
||||||
|
)
|
||||||
|
row = {
|
||||||
|
"obj1": a["name"], "obj2": b["name"],
|
||||||
|
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
|
||||||
|
}
|
||||||
|
if applying is not None:
|
||||||
|
row["applying"] = applying
|
||||||
|
row["as"] = "A" if applying else "S"
|
||||||
|
out.append(row)
|
||||||
|
break # jedna para = jeden aspekt
|
||||||
|
return out
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Złożenie pełnego horoskopu: pozycje + osie + domy (LOG-01 + LOG-05).
|
||||||
|
|
||||||
|
Silnik-agnostyczne: potrzebuje tylko `positions()` oraz (dla osi/domów)
|
||||||
|
`sidereal()`. Jeśli silnik nie umie policzyć czasu gwiazdowego, zwraca same
|
||||||
|
pozycje.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine import zodiac as Z
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.formats import SIGNS, absolute, decimal, in_sign, norm360, sign_index
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(name: str, lon: float, off: float = 0.0) -> dict:
|
||||||
|
lon = norm360(lon - off)
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"sign": SIGNS[sign_index(lon)],
|
||||||
|
"in_sign": in_sign(lon),
|
||||||
|
"decimal": round(lon, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_pos(pdict: dict, off: float) -> None:
|
||||||
|
"""Przelicza etykiety pozycji na wybrany zodiak (in-place). off=0 → bez zmian."""
|
||||||
|
if not off:
|
||||||
|
return
|
||||||
|
lon = norm360(pdict["decimal"] - off)
|
||||||
|
pdict["sign"] = SIGNS[sign_index(lon)]
|
||||||
|
pdict["in_sign"] = in_sign(lon)
|
||||||
|
pdict["absolute"] = absolute(lon)
|
||||||
|
pdict["decimal"] = decimal(lon)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||||
|
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
||||||
|
from app.engine.aspects import find_aspects
|
||||||
|
|
||||||
|
positions = engine.positions(moment)
|
||||||
|
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||||
|
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
||||||
|
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
||||||
|
|
||||||
|
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
||||||
|
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
||||||
|
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
||||||
|
result["zodiac"] = zodiac
|
||||||
|
if zodiac in Z.SIDEREAL:
|
||||||
|
result["ayanamsha"] = round(off, 6)
|
||||||
|
for pdict in result["positions"]:
|
||||||
|
_shift_pos(pdict, off)
|
||||||
|
|
||||||
|
if not hasattr(engine, "sidereal"):
|
||||||
|
return result
|
||||||
|
|
||||||
|
ramc, eps = engine.sidereal(moment)
|
||||||
|
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||||
|
mc = H.compute_mc(ramc, eps)
|
||||||
|
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||||
|
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
|
||||||
|
|
||||||
|
result["house_system"] = system
|
||||||
|
result["angles"] = {
|
||||||
|
"Asc": _fmt("Asc", asc, off),
|
||||||
|
"MC": _fmt("MC", mc, off),
|
||||||
|
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||||
|
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||||
|
}
|
||||||
|
result["cusps"] = [
|
||||||
|
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||||
|
"in_sign": in_sign(norm360(c - off))}
|
||||||
|
for i, c in enumerate(cusp_list)
|
||||||
|
]
|
||||||
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
|
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
|
||||||
|
|
||||||
|
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
||||||
|
from app.engine.firdaria import is_day_birth
|
||||||
|
from app.engine.lots import compute_lots
|
||||||
|
|
||||||
|
pts = {p.name: p.longitude for p in positions}
|
||||||
|
pts["Asc"] = asc
|
||||||
|
day = is_day_birth(pts["Sun"], asc, mc) if "Sun" in pts else True
|
||||||
|
result["sect"] = "day" if day else "night"
|
||||||
|
result["lots"] = [
|
||||||
|
{**lot,
|
||||||
|
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
||||||
|
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
||||||
|
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
||||||
|
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
|
||||||
|
for lot in compute_lots(pts, day, lots_method)
|
||||||
|
]
|
||||||
|
return result
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Firdaria (LOG-11) — perska technika time-lord.
|
||||||
|
|
||||||
|
Sekwencja okresów głównych zależy od sekty (dzień/noc). Sekta: urodzenie dzienne,
|
||||||
|
gdy Słońce jest nad horyzontem, czyli po tej samej stronie osi Asc–Dsc co MC.
|
||||||
|
|
||||||
|
Klasyczne długości okresów (lata): Su 10, Ve 8, Me 13, Mo 9, Sa 11, Ju 12, Ma 7
|
||||||
|
(razem 70) + Węzeł Północny 3 + Węzeł Południowy 2 = 75 lat. Każdy okres główny
|
||||||
|
planety dzieli się na 7 podokresów (sub-lord w tej samej kolejności, cyklicznie).
|
||||||
|
Węzły — bez podokresów (najczęstsza konwencja).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
DAY_ORDER = ["Sun", "Venus", "Mercury", "Moon", "Saturn", "Jupiter", "Mars"]
|
||||||
|
NIGHT_ORDER = ["Moon", "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury"]
|
||||||
|
YEARS = {"Sun": 10, "Venus": 8, "Mercury": 13, "Moon": 9,
|
||||||
|
"Saturn": 11, "Jupiter": 12, "Mars": 7}
|
||||||
|
NODES = [("North Node", 3), ("South Node", 2)]
|
||||||
|
DAYS_PER_YEAR = 365.2422
|
||||||
|
|
||||||
|
|
||||||
|
def is_day_birth(sun_lon: float, asc: float, mc: float) -> bool:
|
||||||
|
"""Słońce nad horyzontem = ta sama półkula osi Asc–Dsc co MC."""
|
||||||
|
return (((sun_lon - asc) % 360.0) < 180.0) == (((mc - asc) % 360.0) < 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _date(birth: datetime, years: float) -> str:
|
||||||
|
return (birth + timedelta(days=years * DAYS_PER_YEAR)).date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria(birth: datetime, sun_lon: float, asc: float, mc: float) -> dict:
|
||||||
|
"""Pełny rozkład Firdarii: sekta, kolejność, okresy główne i podokresy."""
|
||||||
|
day = is_day_birth(sun_lon, asc, mc)
|
||||||
|
order = DAY_ORDER if day else NIGHT_ORDER
|
||||||
|
majors = [(lord, YEARS[lord]) for lord in order] + NODES
|
||||||
|
|
||||||
|
periods: list[dict] = []
|
||||||
|
age = 0.0
|
||||||
|
for lord, yrs in majors:
|
||||||
|
period = {"lord": lord, "years": yrs,
|
||||||
|
"start": _date(birth, age), "end": _date(birth, age + yrs)}
|
||||||
|
if lord in YEARS: # planeta -> 7 podokresów
|
||||||
|
sub_len = yrs / 7.0
|
||||||
|
i = order.index(lord)
|
||||||
|
sub_age = age
|
||||||
|
subs: list[dict] = []
|
||||||
|
for k in range(7):
|
||||||
|
sub_lord = order[(i + k) % 7]
|
||||||
|
subs.append({"lord": sub_lord,
|
||||||
|
"start": _date(birth, sub_age),
|
||||||
|
"end": _date(birth, sub_age + sub_len)})
|
||||||
|
sub_age += sub_len
|
||||||
|
period["sub"] = subs
|
||||||
|
periods.append(period)
|
||||||
|
age += yrs
|
||||||
|
return {"sect": "day" if day else "night", "order": order, "periods": periods}
|
||||||
@@ -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 0–360°. 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 0–360°."""
|
||||||
|
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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Lots / punkty arabskie (LOG-08) — 7 Lots hermetycznych.
|
||||||
|
|
||||||
|
Formuła: Lot = C + A − B (od punktu C odmierzamy odległość między A i B).
|
||||||
|
Większość Lots **odwraca się w horoskopach nocnych** (zamiana A↔B) — np.
|
||||||
|
Fortuna: dzień Asc + Mo − Su, noc Asc + Su − Mo.
|
||||||
|
|
||||||
|
Dwa warianty liczenia (notes3):
|
||||||
|
- `degree` (domyślny) — dokładny stopień,
|
||||||
|
- `sign` — liczone całymi znakami (Lot wypada na 0° wyliczonego znaku).
|
||||||
|
|
||||||
|
Kolejność ma znaczenie: Fortuna i Duch liczone są pierwsze, bo pozostałe Lots
|
||||||
|
odwołują się do nich.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.formats import norm360, sign_index
|
||||||
|
|
||||||
|
# (nazwa, C, A, B, odwracalny w nocy)
|
||||||
|
LOT_DEFS: list[tuple[str, str, str, str, bool]] = [
|
||||||
|
("Fortune", "Asc", "Moon", "Sun", True),
|
||||||
|
("Spirit", "Asc", "Sun", "Moon", True),
|
||||||
|
("Eros", "Asc", "Venus", "Spirit", True),
|
||||||
|
("Necessity", "Asc", "Fortune", "Mercury", True),
|
||||||
|
("Courage", "Asc", "Fortune", "Mars", True),
|
||||||
|
("Victory", "Asc", "Jupiter", "Spirit", True),
|
||||||
|
("Nemesis", "Asc", "Fortune", "Saturn", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
METHODS = ("degree", "sign")
|
||||||
|
|
||||||
|
|
||||||
|
def compute_lots(
|
||||||
|
points: dict[str, float], is_day: bool, method: str = "degree"
|
||||||
|
) -> list[dict]:
|
||||||
|
"""points: nazwa → długość natalna (wymagane Asc + planety formuł).
|
||||||
|
|
||||||
|
Zwraca listę {name, longitude, formula} w kolejności definicji.
|
||||||
|
"""
|
||||||
|
if method not in METHODS:
|
||||||
|
raise ValueError(f"nieznana metoda liczenia Lots: {method}")
|
||||||
|
|
||||||
|
vals = dict(points)
|
||||||
|
out: list[dict] = []
|
||||||
|
for name, c, a, b, reversible in LOT_DEFS:
|
||||||
|
first, second = (a, b) if (is_day or not reversible) else (b, a)
|
||||||
|
if any(k not in vals for k in (c, first, second)):
|
||||||
|
continue # brak składnika — pomijamy
|
||||||
|
if method == "sign":
|
||||||
|
idx = (sign_index(vals[c]) + sign_index(vals[first])
|
||||||
|
- sign_index(vals[second])) % 12
|
||||||
|
lon = idx * 30.0
|
||||||
|
else:
|
||||||
|
lon = norm360(vals[c] + vals[first] - vals[second])
|
||||||
|
vals[name] = lon # dostępny dla kolejnych Lots
|
||||||
|
out.append({
|
||||||
|
"name": name,
|
||||||
|
"longitude": lon,
|
||||||
|
"formula": f"{c} + {first} − {second}",
|
||||||
|
})
|
||||||
|
return out
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Modele domenowe silnika efemeryd — wspólny kontrakt dla KAŻDEGO silnika.
|
||||||
|
|
||||||
|
To jest część LOG-28 (parzystość): każdy silnik (własny Skyfield czy zdalny
|
||||||
|
swisseph) przyjmuje `ChartMoment` i zwraca listę `ObjectPosition` w identycznym
|
||||||
|
kształcie i jednostkach. Dzięki temu wyniki są bezpośrednio porównywalne, a
|
||||||
|
prezentacja/dane nie wiedzą, który silnik liczył.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.engine import formats
|
||||||
|
|
||||||
|
# kanoniczny zestaw i kolejność obiektów (LOG-02: światła + 7 klasycznych +
|
||||||
|
# 3 nowożytne + punkty wirtualne: węzły mean i mean Lilith)
|
||||||
|
DEFAULT_OBJECTS = [
|
||||||
|
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||||
|
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
|
||||||
|
"North Node", "South Node", "Lilith",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChartMoment:
|
||||||
|
"""Wejście silnika: moment w UTC + lokalizacja geograficzna.
|
||||||
|
|
||||||
|
Pozycje obiektów zależą tylko od czasu (geocentrycznie); szerokość/długość
|
||||||
|
geograficzna będą potrzebne dopiero przy osiach i domach (LOG-05).
|
||||||
|
"""
|
||||||
|
|
||||||
|
when_utc: datetime # musi być świadome strefy (UTC)
|
||||||
|
lat: float = 0.0 # szerokość geograficzna, + na północ
|
||||||
|
lon: float = 0.0 # długość geograficzna, + na wschód
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ObjectPosition:
|
||||||
|
"""Wynik dla jednego obiektu — surowe wartości w jednostkach SI astrologii."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
longitude: float # długość ekliptyczna 0–360° (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,43 @@
|
|||||||
|
"""Punkty wirtualne liczone analitycznie (LOG-02): mean Node i mean Lilith.
|
||||||
|
|
||||||
|
Wzory Meeusa (Astronomical Algorithms) w stuleciach juliańskich od J2000 (TT):
|
||||||
|
- Ω — średni węzeł wstępujący orbity Księżyca (mean ascending node). Porusza się
|
||||||
|
zawsze wstecz (~−0,053°/dobę) — stąd węzły są wiecznie Rx.
|
||||||
|
- średnie perygeum orbity Księżyca; mean Lilith (Black Moon) = średnie APOGEUM
|
||||||
|
= perygeum + 180° (~+0,111°/dobę).
|
||||||
|
|
||||||
|
Wersje TRUE (oskulacyjne) — osobny, późniejszy krok (notatki: mean to
|
||||||
|
historyczny standard i domyślne ustawienie programów).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
_DAYS_PER_CENTURY = 36525.0
|
||||||
|
|
||||||
|
|
||||||
|
def _t(tt_jd: float) -> float:
|
||||||
|
return (tt_jd - 2451545.0) / _DAYS_PER_CENTURY
|
||||||
|
|
||||||
|
|
||||||
|
def mean_lunar_node(tt_jd: float) -> float:
|
||||||
|
"""Długość ekliptyczna średniego Węzła Północnego (Ω) [°]."""
|
||||||
|
t = _t(tt_jd)
|
||||||
|
omega = (125.0445479 - 1934.1362891 * t + 0.0020754 * t * t
|
||||||
|
+ t ** 3 / 467441.0 - t ** 4 / 60616000.0)
|
||||||
|
return norm360(omega)
|
||||||
|
|
||||||
|
|
||||||
|
def mean_lilith(tt_jd: float) -> float:
|
||||||
|
"""Długość ekliptyczna mean Lilith (średnie apogeum Księżyca) [°]."""
|
||||||
|
t = _t(tt_jd)
|
||||||
|
perigee = (83.3532465 + 4069.0137287 * t - 0.0103200 * t * t
|
||||||
|
- t ** 3 / 80053.0 + t ** 4 / 18999000.0)
|
||||||
|
return norm360(perigee + 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def point_speed(fn, tt_jd: float, dt_days: float = 0.1) -> float:
|
||||||
|
"""Prędkość [°/dobę] punktu analitycznego — różnica po małym kroku."""
|
||||||
|
a = fn(tt_jd)
|
||||||
|
b = fn(tt_jd + dt_days)
|
||||||
|
return (((b - a + 180.0) % 360.0) - 180.0) / dt_days
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Profekcje roczne (LOG-10) — hellenistyczna technika time-lord.
|
||||||
|
|
||||||
|
Zasada (Whole Sign): co każde urodziny profektowany Ascendent przeskakuje o jeden
|
||||||
|
znak do przodu (wiek mod 12). Władca Roku (Lord of Year) = władca domicylowy
|
||||||
|
znaku profektowanego Asc. Profektować można każdy punkt natalny (MC, Słońce…) —
|
||||||
|
wszystkie przeskakują o tyle samo znaków.
|
||||||
|
|
||||||
|
Referencja: tabela profekcji w notes3 (astro-seek) dla horoskopu 30.04.1984
|
||||||
|
(wiek 0: Can/Moon, 1: Leo/Sun, …, 42: Cap/Saturn).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.engine.formats import SIGNS, sign_index
|
||||||
|
|
||||||
|
# władcy domicylowi (tradycyjni) — zgodni z tabelą referencyjną notes3
|
||||||
|
DOMICILE_RULERS = {
|
||||||
|
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon",
|
||||||
|
"Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars",
|
||||||
|
"Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn",
|
||||||
|
"Pisces": "Jupiter",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def age_at(birth_utc: datetime, when_utc: datetime) -> int:
|
||||||
|
"""Pełne lata między urodzeniem a danym momentem (wiek profekcyjny)."""
|
||||||
|
age = when_utc.year - birth_utc.year
|
||||||
|
if (when_utc.month, when_utc.day) < (birth_utc.month, birth_utc.day):
|
||||||
|
age -= 1
|
||||||
|
return max(age, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def profected_sign(natal_lon: float, age: int) -> str:
|
||||||
|
"""Znak, do którego profektował punkt natalny po `age` latach."""
|
||||||
|
return SIGNS[(sign_index(natal_lon) + age) % 12]
|
||||||
|
|
||||||
|
|
||||||
|
def profection_rows(
|
||||||
|
natal_points: dict[str, float],
|
||||||
|
birth_utc: datetime,
|
||||||
|
start_age: int,
|
||||||
|
count: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Tabela profekcji dla zakresu lat życia.
|
||||||
|
|
||||||
|
natal_points: nazwa -> natalna długość ekliptyczna (musi zawierać 'Asc').
|
||||||
|
Każdy wiersz: wiek, data początku roku profekcyjnego (urodziny), znak
|
||||||
|
profektowanego Asc, Władca Roku oraz profekcje pozostałych punktów.
|
||||||
|
"""
|
||||||
|
def _birthday(year: int) -> datetime:
|
||||||
|
try:
|
||||||
|
return birth_utc.replace(year=year)
|
||||||
|
except ValueError: # 29 lutego w roku nieprzestępnym
|
||||||
|
return birth_utc.replace(year=year, day=28)
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for age in range(start_age, start_age + count):
|
||||||
|
asc_sign = profected_sign(natal_points["Asc"], age)
|
||||||
|
row = {
|
||||||
|
"age": age,
|
||||||
|
"from": _birthday(birth_utc.year + age).strftime("%Y-%m-%d"),
|
||||||
|
"profected_asc": asc_sign,
|
||||||
|
"lord_of_year": DOMICILE_RULERS[asc_sign],
|
||||||
|
}
|
||||||
|
for name, lon in natal_points.items():
|
||||||
|
if name != "Asc":
|
||||||
|
row[name] = profected_sign(lon, age)
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""RemoteEngine — klient izolowanej usługi silnika (LOG-24 backend nr 2, LOG-27).
|
||||||
|
|
||||||
|
Realizuje ten sam interfejs co SkyfieldEngine, ale liczenie deleguje przez HTTP
|
||||||
|
do OSOBNEJ usługi `engine-swisseph` (AGPL). Dzięki granicy sieciowej kod AGPL
|
||||||
|
nigdy nie jest linkowany do permisywnego produktu — patrz services/engine-swisseph.
|
||||||
|
|
||||||
|
Używany tylko, gdy skonfigurowano ENGINE_SWISSEPH_URL (tryb porównawczy/dev/CI).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteEngine(EphemerisEngine):
|
||||||
|
name = "swisseph"
|
||||||
|
|
||||||
|
def __init__(self, base_url: str | None = None, timeout: float = 15.0) -> None:
|
||||||
|
self.base_url = (base_url or os.getenv("ENGINE_SWISSEPH_URL", "")).rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def positions(
|
||||||
|
self, moment: ChartMoment, objects: list[str] | None = None
|
||||||
|
) -> list[ObjectPosition]:
|
||||||
|
if not self.base_url:
|
||||||
|
raise RuntimeError("ENGINE_SWISSEPH_URL nie ustawiony — silnik B niedostępny")
|
||||||
|
payload = {
|
||||||
|
"when_utc": moment.when_utc.isoformat(),
|
||||||
|
"lat": moment.lat,
|
||||||
|
"lon": moment.lon,
|
||||||
|
"objects": objects or DEFAULT_OBJECTS,
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
r = client.post(f"{self.base_url}/positions", json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
rows = r.json()["positions"]
|
||||||
|
return [
|
||||||
|
ObjectPosition(
|
||||||
|
name=row["name"],
|
||||||
|
longitude=norm360(row["longitude"]),
|
||||||
|
latitude=row["latitude"],
|
||||||
|
speed=row["speed"],
|
||||||
|
retrograde=row["retrograde"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
if not self.base_url:
|
||||||
|
return {"engine": self.name, "status": "disabled"}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
r = client.get(f"{self.base_url}/health")
|
||||||
|
r.raise_for_status()
|
||||||
|
return {"engine": self.name, "status": "ok", "remote": r.json()}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"engine": self.name, "status": "down", "error": str(e)}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Solar / Lunar Return (LOG-12) — moment powrotu do pozycji natalnej.
|
||||||
|
|
||||||
|
Solar Return (solariusz): moment, w którym Słońce wraca dokładnie do natalnej
|
||||||
|
długości ekliptycznej (raz na rok, w okolicy urodzin). Lunar Return: to samo
|
||||||
|
dla Księżyca (raz na ~27,3 dnia). Dwa warianty użycia (osobny horoskop vs
|
||||||
|
tranzyt do natalu) obsługujemy zwracając pełny horoskop na znaleziony moment —
|
||||||
|
interpretacja pozostaje po stronie technik wyżej.
|
||||||
|
|
||||||
|
Metoda: podpisana różnica długości Δ = lon − natal (zawinięta do ±180°) rośnie
|
||||||
|
monotonicznie i przechodzi przez 0 dokładnie w momencie powrotu. Skan dobowy
|
||||||
|
wykrywa przejście −→+ (skok +180→−180 to artefakt zawinięcia — pomijany,
|
||||||
|
warunek d_hi − d_lo < 180), potem bisekcja do ~sekundy.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
# szerokość okna skanu wokół `around` [dni]: solar kotwiczymy przy urodzinach,
|
||||||
|
# lunar musi objąć cały okres syderyczny Księżyca (27,3 d)
|
||||||
|
SCAN_WINDOW = {"solar": 6.0, "lunar": 15.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _lon_delta(engine, body: str, natal_lon: float, when: datetime) -> float:
|
||||||
|
m = ChartMoment(when_utc=when)
|
||||||
|
lon = engine.positions(m, [body])[0].longitude
|
||||||
|
return ((lon - natal_lon + 180.0) % 360.0) - 180.0
|
||||||
|
|
||||||
|
|
||||||
|
def find_return(
|
||||||
|
engine, kind: str, natal_moment: ChartMoment, around: datetime
|
||||||
|
) -> datetime | None:
|
||||||
|
"""Moment powrotu (kind: 'solar'/'lunar') najbliższy dacie `around`."""
|
||||||
|
body = "Sun" if kind == "solar" else "Moon"
|
||||||
|
natal_lon = engine.positions(natal_moment, [body])[0].longitude
|
||||||
|
if around.tzinfo is None:
|
||||||
|
around = around.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
window = SCAN_WINDOW[kind]
|
||||||
|
step = timedelta(days=1.0)
|
||||||
|
t = around - timedelta(days=window)
|
||||||
|
end = around + timedelta(days=window)
|
||||||
|
|
||||||
|
candidates: list[datetime] = []
|
||||||
|
d_prev = _lon_delta(engine, body, natal_lon, t)
|
||||||
|
while t < end:
|
||||||
|
t_next = t + step
|
||||||
|
d_next = _lon_delta(engine, body, natal_lon, t_next)
|
||||||
|
# prawdziwe przejście przez zero: − -> + bez skoku zawinięcia
|
||||||
|
if d_prev < 0 <= d_next and (d_next - d_prev) < 180.0:
|
||||||
|
lo, hi, d_lo = t, t_next, d_prev
|
||||||
|
for _ in range(40): # bisekcja do ułamka sekundy
|
||||||
|
mid = lo + (hi - lo) / 2
|
||||||
|
if (_lon_delta(engine, body, natal_lon, mid) < 0) == (d_lo < 0):
|
||||||
|
lo = mid
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
candidates.append(lo + (hi - lo) / 2)
|
||||||
|
t, d_prev = t_next, d_next
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
return min(candidates, key=lambda c: abs(c - around))
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""SkyfieldEngine — własny, permisywny silnik (LOG-01).
|
||||||
|
|
||||||
|
Ścieżka A: Skyfield (MIT) + efemerydy JPL (public domain). Liczy geocentryczne
|
||||||
|
pozycje pozorne (apparent) i rzutuje je na ekliptykę daty → długość tropikalna,
|
||||||
|
szerokość, prędkość i kierunek. Brak zależności AGPL.
|
||||||
|
|
||||||
|
Prędkość liczymy numerycznie (różnica długości po małym kroku czasu) — wystarcza
|
||||||
|
do kierunku (D/Rx) i do wykrywania stacji w LOG-03.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
# nazwa obiektu -> cel w jądrze efemeryd (de421 ma centra Merkurego/Wenus,
|
||||||
|
# dla pozostałych planet używamy barycentrów — różnica nieistotna astrologicznie)
|
||||||
|
_TARGETS = {
|
||||||
|
"Sun": "sun",
|
||||||
|
"Moon": "moon",
|
||||||
|
"Mercury": "mercury",
|
||||||
|
"Venus": "venus",
|
||||||
|
"Mars": "mars barycenter",
|
||||||
|
"Jupiter": "jupiter barycenter",
|
||||||
|
"Saturn": "saturn barycenter",
|
||||||
|
"Uranus": "uranus barycenter",
|
||||||
|
"Neptune": "neptune barycenter",
|
||||||
|
"Pluto": "pluto barycenter",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def _load(kernel: str, data_dir: str):
|
||||||
|
"""Wczytuje skalę czasu i jądro efemeryd raz (kosztowne) i cache'uje."""
|
||||||
|
from skyfield.api import Loader
|
||||||
|
|
||||||
|
load = Loader(data_dir)
|
||||||
|
ts = load.timescale()
|
||||||
|
eph = load(kernel)
|
||||||
|
return ts, eph, eph["earth"]
|
||||||
|
|
||||||
|
|
||||||
|
class SkyfieldEngine(EphemerisEngine):
|
||||||
|
name = "skyfield"
|
||||||
|
|
||||||
|
def __init__(self, kernel: str | None = None, data_dir: str | None = None) -> None:
|
||||||
|
self.kernel = kernel or os.getenv("EPHEMERIS_KERNEL", "de421.bsp")
|
||||||
|
self.data_dir = data_dir or os.getenv(
|
||||||
|
"EPHEMERIS_DIR", os.path.join(os.path.dirname(__file__), "..", "..", ".ephemeris")
|
||||||
|
)
|
||||||
|
os.makedirs(self.data_dir, exist_ok=True)
|
||||||
|
self.ts, self.eph, self.earth = _load(self.kernel, os.path.abspath(self.data_dir))
|
||||||
|
|
||||||
|
def _ecliptic_lon_lat(self, target, t):
|
||||||
|
astrometric = self.earth.at(t).observe(target).apparent()
|
||||||
|
lat, lon, _dist = astrometric.ecliptic_latlon(epoch="date")
|
||||||
|
return lon.degrees, lat.degrees
|
||||||
|
|
||||||
|
def _virtual_point(self, name: str, tt_jd: float) -> ObjectPosition:
|
||||||
|
"""Punkty analityczne (LOG-02): mean Node (NN/SN) i mean Lilith.
|
||||||
|
|
||||||
|
Liczone wzorami Meeusa, nie z jądra JPL. SN = NN + 180° (ta sama prędkość).
|
||||||
|
Punkty leżą na ekliptyce (latitude = 0).
|
||||||
|
"""
|
||||||
|
from app.engine.points import mean_lilith, mean_lunar_node, point_speed
|
||||||
|
|
||||||
|
if name in ("North Node", "South Node"):
|
||||||
|
lon = mean_lunar_node(tt_jd)
|
||||||
|
if name == "South Node":
|
||||||
|
lon = norm360(lon + 180.0)
|
||||||
|
speed = point_speed(mean_lunar_node, tt_jd)
|
||||||
|
else: # Lilith
|
||||||
|
lon = mean_lilith(tt_jd)
|
||||||
|
speed = point_speed(mean_lilith, tt_jd)
|
||||||
|
return ObjectPosition(
|
||||||
|
name=name, longitude=float(lon), latitude=0.0,
|
||||||
|
speed=float(speed), retrograde=bool(speed < 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def positions(
|
||||||
|
self, moment: ChartMoment, objects: list[str] | None = None
|
||||||
|
) -> list[ObjectPosition]:
|
||||||
|
names = objects or DEFAULT_OBJECTS
|
||||||
|
t = self.ts.from_datetime(moment.when_utc)
|
||||||
|
dt = timedelta(hours=1)
|
||||||
|
t2 = self.ts.from_datetime(moment.when_utc + dt)
|
||||||
|
|
||||||
|
out: list[ObjectPosition] = []
|
||||||
|
for name in names:
|
||||||
|
if name not in _TARGETS: # punkt wirtualny (NN/SN/Lilith)
|
||||||
|
out.append(self._virtual_point(name, t.tt))
|
||||||
|
continue
|
||||||
|
target = self.eph[_TARGETS[name]]
|
||||||
|
lon, lat = self._ecliptic_lon_lat(target, t)
|
||||||
|
lon2, _ = self._ecliptic_lon_lat(target, t2)
|
||||||
|
# prędkość °/dobę z poprawką na przejście przez 0°/360°
|
||||||
|
step = ((lon2 - lon + 180.0) % 360.0) - 180.0
|
||||||
|
speed = step * 24.0
|
||||||
|
# rzutowanie na czysty float — Skyfield zwraca numpy.float64
|
||||||
|
out.append(
|
||||||
|
ObjectPosition(
|
||||||
|
name=name,
|
||||||
|
longitude=float(norm360(lon)),
|
||||||
|
latitude=float(lat),
|
||||||
|
speed=float(speed),
|
||||||
|
retrograde=bool(speed < 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def sidereal(self, moment: ChartMoment) -> tuple[float, float]:
|
||||||
|
"""(RAMC, ε) w stopniach — lokalny apparent sidereal time i nachylenie ekliptyki.
|
||||||
|
|
||||||
|
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
|
||||||
|
"""
|
||||||
|
from app.engine.houses import mean_obliquity
|
||||||
|
|
||||||
|
t = self.ts.from_datetime(moment.when_utc)
|
||||||
|
ramc = norm360(t.gast * 15.0 + moment.lon)
|
||||||
|
eps = mean_obliquity(t.tt)
|
||||||
|
return ramc, eps
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Wykrywanie stacji planet (LOG-03): poprzednia/następna stacja, SD/SR, flaga <7 dni.
|
||||||
|
|
||||||
|
Stacja ścisła = moment, w którym prędkość zodiakalna przechodzi przez zero.
|
||||||
|
Metoda: próbki prędkości co 1 dzień w oknie ± SEARCH_DAYS → zmiana znaku →
|
||||||
|
bisekcja do dokładności ~1 minuty. Klasyfikacja: prędkość przed<0 i po>0 → SD
|
||||||
|
(stationary direct), odwrotnie → SR (stationary retrograde).
|
||||||
|
|
||||||
|
Pomijamy Słońce/Księżyc (nigdy Rx) i punkty mean (NN/SN/Lilith — ruch jednostajny).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.engine.formats import in_sign
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
# obiekty bez stacji
|
||||||
|
NO_STATIONS = {"Sun", "Moon", "North Node", "South Node", "Lilith"}
|
||||||
|
|
||||||
|
# Okno musi pokryć najdłuższą przerwę między stacjami (Mars/Wenus: ~700 dni),
|
||||||
|
# a krok skanu musi być krótszy niż najkrótsza retrogradacja (Merkury ~21 dni).
|
||||||
|
SEARCH_DAYS = 800 # okno poszukiwań w każdą stronę
|
||||||
|
SCAN_STEP_DAYS = 4.0 # krok zgrubnego skanu (potem bisekcja)
|
||||||
|
STATION_SOON_DAYS = 7.0 # próg flagi "stacja blisko" (konfigurowalny, notes2)
|
||||||
|
|
||||||
|
|
||||||
|
def _speed_fn(engine, name: str):
|
||||||
|
"""Zwraca funkcję: dni_od_momentu_bazowego -> prędkość [°/dobę]."""
|
||||||
|
def speed(base_moment: ChartMoment, offset_days: float) -> float:
|
||||||
|
m = ChartMoment(
|
||||||
|
when_utc=base_moment.when_utc + timedelta(days=offset_days),
|
||||||
|
lat=base_moment.lat, lon=base_moment.lon,
|
||||||
|
)
|
||||||
|
return engine.positions(m, [name])[0].speed
|
||||||
|
return speed
|
||||||
|
|
||||||
|
|
||||||
|
def _bisect_zero(speed, moment: ChartMoment, lo: float, hi: float, iters: int = 20) -> float:
|
||||||
|
"""Bisekcja miejsca zerowego prędkości między dniami lo i hi."""
|
||||||
|
s_lo = speed(moment, lo)
|
||||||
|
for _ in range(iters):
|
||||||
|
mid = (lo + hi) / 2.0
|
||||||
|
s_mid = speed(moment, mid)
|
||||||
|
if (s_lo < 0) == (s_mid < 0):
|
||||||
|
lo, s_lo = mid, s_mid
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
return (lo + hi) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _station_info(engine, moment: ChartMoment, name: str, day: float, speed) -> dict:
|
||||||
|
"""Opis stacji w danym dniu (offset od momentu bazowego)."""
|
||||||
|
before = speed(moment, day - 0.5)
|
||||||
|
kind = "SD" if before < 0 else "SR"
|
||||||
|
when = moment.when_utc + timedelta(days=day)
|
||||||
|
m = ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
|
||||||
|
lon = engine.positions(m, [name])[0].longitude
|
||||||
|
return {
|
||||||
|
"type": kind,
|
||||||
|
"date": when.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"days": round(day, 1), # ujemne = w przeszłości
|
||||||
|
"degree": in_sign(lon),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_stations(engine, moment: ChartMoment, name: str, step_days: float = SCAN_STEP_DAYS) -> dict | None:
|
||||||
|
"""Poprzednia i następna stacja obiektu względem momentu horoskopu."""
|
||||||
|
if name in NO_STATIONS:
|
||||||
|
return None
|
||||||
|
speed = _speed_fn(engine, name)
|
||||||
|
|
||||||
|
prev_day = next_day = None
|
||||||
|
# w przeszłość
|
||||||
|
s_right = speed(moment, 0.0)
|
||||||
|
d = 0.0
|
||||||
|
while d > -SEARCH_DAYS:
|
||||||
|
s_left = speed(moment, d - step_days)
|
||||||
|
if (s_left < 0) != (s_right < 0):
|
||||||
|
prev_day = _bisect_zero(speed, moment, d - step_days, d)
|
||||||
|
break
|
||||||
|
d, s_right = d - step_days, s_left
|
||||||
|
# w przyszłość
|
||||||
|
s_left = speed(moment, 0.0)
|
||||||
|
d = 0.0
|
||||||
|
while d < SEARCH_DAYS:
|
||||||
|
s_right = speed(moment, d + step_days)
|
||||||
|
if (s_left < 0) != (s_right < 0):
|
||||||
|
next_day = _bisect_zero(speed, moment, d, d + step_days)
|
||||||
|
break
|
||||||
|
d, s_left = d + step_days, s_right
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
if prev_day is not None:
|
||||||
|
result["prev"] = _station_info(engine, moment, name, prev_day, speed)
|
||||||
|
if next_day is not None:
|
||||||
|
result["next"] = _station_info(engine, moment, name, next_day, speed)
|
||||||
|
result["station_soon"] = any(
|
||||||
|
abs(x["days"]) < STATION_SOON_DAYS for x in result.values() if isinstance(x, dict)
|
||||||
|
)
|
||||||
|
return result or None
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Zbiorcza tabela dat z technik (LOG-14).
|
||||||
|
|
||||||
|
Spina w jedną, posortowaną oś czasu daty z kilku technik:
|
||||||
|
- profekcje roczne (LOG-10) — rok życia,
|
||||||
|
- Solar Return (LOG-12) — moment powrotu Słońca,
|
||||||
|
- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów
|
||||||
|
natalnych (wzorzec z notes3: „Profection planet | Aspect | Birth planet |
|
||||||
|
Exact Date"). Klucz łuku konfigurowalny; domyślnie Naiboda (0°59'08"/rok).
|
||||||
|
|
||||||
|
Każdy wiersz ma kształt z notes2: technique | significator | start | exact | end.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.engine.aspects import MAJOR, PL_NAME
|
||||||
|
from app.engine.profections import DOMICILE_RULERS, profected_sign
|
||||||
|
from app.engine.returns import find_return
|
||||||
|
|
||||||
|
NAIBOD_KEY = 0.9856472 # °/rok (0°59'08") — domyślny klucz solar-arc
|
||||||
|
DAYS_PER_YEAR = 365.2422
|
||||||
|
DIRECTED = ["Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||||
|
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_years(birth: datetime, years: float) -> datetime:
|
||||||
|
return birth + timedelta(days=years * DAYS_PER_YEAR)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(technique, significator, start, exact, end) -> dict:
|
||||||
|
def iso(x):
|
||||||
|
return x.date().isoformat() if isinstance(x, datetime) else x
|
||||||
|
return {"technique": technique, "significator": significator,
|
||||||
|
"start": iso(start), "exact": iso(exact), "end": iso(end)}
|
||||||
|
|
||||||
|
|
||||||
|
def solar_arc_directions(
|
||||||
|
natal: dict[str, float], birth: datetime, lo: datetime, hi: datetime,
|
||||||
|
key: float = NAIBOD_KEY, orb_years: float = 1.0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Daty dyrekcji solar-arc w oknie [lo, hi].
|
||||||
|
|
||||||
|
natal: nazwa punktu -> długość natalna (planety + Asc/MC). Kierowane są planety
|
||||||
|
(DIRECTED), celem każdy punkt natalny. Aspekt dokładny gdy łuk = odległość
|
||||||
|
kątowa (mod 360). Wiek = łuk/klucz; data = urodziny + wiek.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
lo_age = (lo - birth).days / DAYS_PER_YEAR - orb_years
|
||||||
|
hi_age = (hi - birth).days / DAYS_PER_YEAR + orb_years
|
||||||
|
for p in DIRECTED:
|
||||||
|
if p not in natal:
|
||||||
|
continue
|
||||||
|
for q, q_lon in natal.items():
|
||||||
|
for asp, angle in MAJOR.items():
|
||||||
|
for target in ({angle, (360.0 - angle) % 360.0}):
|
||||||
|
arc = (q_lon + target - natal[p]) % 360.0
|
||||||
|
age = arc / key
|
||||||
|
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
|
||||||
|
continue
|
||||||
|
exact = _add_years(birth, age)
|
||||||
|
row = _row(
|
||||||
|
"solar_arc",
|
||||||
|
f"dyr. {p} {PL_NAME[asp]} {q}",
|
||||||
|
_add_years(birth, age - orb_years),
|
||||||
|
exact,
|
||||||
|
_add_years(birth, age + orb_years),
|
||||||
|
)
|
||||||
|
row.update(directed=p, aspect=asp, target=q) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def profection_events(natal_asc: float, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Lata profekcyjne (LOG-10) nachodzące na okno."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for age in range((lo.year - birth.year) - 1, (hi.year - birth.year) + 1):
|
||||||
|
if age < 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start = birth.replace(year=birth.year + age)
|
||||||
|
end = birth.replace(year=birth.year + age + 1)
|
||||||
|
except ValueError: # 29 lutego
|
||||||
|
start = birth.replace(year=birth.year + age, day=28)
|
||||||
|
end = birth.replace(year=birth.year + age + 1, day=28)
|
||||||
|
if end < lo or start > hi:
|
||||||
|
continue
|
||||||
|
sign = profected_sign(natal_asc, age)
|
||||||
|
lord = DOMICILE_RULERS[sign]
|
||||||
|
row = _row(
|
||||||
|
"profection", f"Władca Roku: {lord} (Asc {sign}, wiek {age})",
|
||||||
|
start, start, end,
|
||||||
|
)
|
||||||
|
row.update(lord=lord, sign=sign) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def solar_return_events(engine, natal_moment, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Solariusze w oknie (LOG-12) — jeden na rok."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for year in range(lo.year, hi.year + 1):
|
||||||
|
try:
|
||||||
|
around = birth.replace(year=year)
|
||||||
|
except ValueError:
|
||||||
|
around = birth.replace(year=year, day=28)
|
||||||
|
hit = find_return(engine, "solar", natal_moment, around)
|
||||||
|
if hit and lo <= hit <= hi:
|
||||||
|
out.append(_row("solar_return", "Solar Return", hit, hit, _add_years(hit, 1)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria_events(natal_points: dict[str, float], birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Starty okresów/podokresów Firdarii (LOG-11) nachodzące na okno."""
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
|
||||||
|
fd = firdaria(birth, natal_points["Sun"], natal_points["Asc"], natal_points["MC"])
|
||||||
|
out: list[dict] = []
|
||||||
|
for period in fd["periods"]:
|
||||||
|
if "sub" in period:
|
||||||
|
for s in period["sub"]:
|
||||||
|
if lo <= _as_dt(s["start"]) <= hi:
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']} / {s['lord']}",
|
||||||
|
s["start"], s["start"], s["end"])
|
||||||
|
row.update(fd_major=period["lord"], fd_sub=s["lord"])
|
||||||
|
out.append(row)
|
||||||
|
elif lo <= _as_dt(period["start"]) <= hi: # węzeł — bez podokresów
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']}",
|
||||||
|
period["start"], period["start"], period["end"])
|
||||||
|
row.update(fd_major=period["lord"])
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dt(d) -> datetime:
|
||||||
|
if isinstance(d, datetime):
|
||||||
|
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||||||
|
if isinstance(d, date):
|
||||||
|
return datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||||
|
return datetime.fromisoformat(str(d)).replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def build_timeline(
|
||||||
|
engine, natal_moment, natal_points: dict[str, float],
|
||||||
|
from_d, to_d, techniques: list[str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
|
||||||
|
lo, hi = _as_dt(from_d), _as_dt(to_d)
|
||||||
|
birth = natal_moment.when_utc
|
||||||
|
want = set(techniques or ["profection", "solar_return", "solar_arc", "firdaria"])
|
||||||
|
events: list[dict] = []
|
||||||
|
if "profection" in want:
|
||||||
|
events += profection_events(natal_points["Asc"], birth, lo, hi)
|
||||||
|
if "solar_return" in want:
|
||||||
|
events += solar_return_events(engine, natal_moment, birth, lo, hi)
|
||||||
|
if "solar_arc" in want:
|
||||||
|
events += solar_arc_directions(natal_points, birth, lo, hi)
|
||||||
|
if "firdaria" in want:
|
||||||
|
events += firdaria_events(natal_points, birth, lo, hi)
|
||||||
|
events.sort(key=lambda e: e["exact"])
|
||||||
|
return events
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Systemy zodiaku (LOG-04): tropikalny, syderyczny (ayanamsy), draconic + RA.
|
||||||
|
|
||||||
|
Wszystkie pozycje silnika są liczone **tropikalnie of-date** (kontrakt LOG-28).
|
||||||
|
Zmiana zodiaku to — dla zodiaków ekliptycznych — jednolite przesunięcie długości:
|
||||||
|
|
||||||
|
długość_docelowa = (długość_tropikalna − offset) mod 360
|
||||||
|
|
||||||
|
gdzie offset to:
|
||||||
|
- **syderyczny**: ayanamsa (kąt między tropikalnym a syderycznym punktem Barana),
|
||||||
|
- **draconic**: długość wznoszącego węzła Księżyca (węzeł = 0° draconic),
|
||||||
|
- **tropikalny**: 0.
|
||||||
|
|
||||||
|
Ponieważ to stałe przesunięcie obiektu ORAZ cusps, **numery domów się nie zmieniają**
|
||||||
|
(geometria jest niezmiennicza względem obrotu) — przesuwamy tylko etykiety znaków.
|
||||||
|
|
||||||
|
Model ayanamsy: `ayan(jd) = ayan0 + B·x + C·x²`, gdzie `x = jd − J2000`. Prędkość
|
||||||
|
precesji (B, C) jest **wspólna** dla wszystkich ayanams; różni je tylko stała `ayan0`
|
||||||
|
(wybór syderycznego zera). Stałe skalibrowano do Swiss Ephemeris jako wyroczni —
|
||||||
|
zgodność do ~0,02" w latach 1900–2100 (patrz tests/test_zodiac.py).
|
||||||
|
|
||||||
|
RA (right ascension): konwersja ekliptyka→równik dla przyszłego widoku równikowego.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
TROPICAL = "tropical"
|
||||||
|
DRACONIC = "draconic"
|
||||||
|
|
||||||
|
# stała ayanamsy w J2000.0 (°) — skalibrowana do swisseph (get_ayanamsa_ut)
|
||||||
|
_AYAN0 = {
|
||||||
|
"lahiri": 23.857092,
|
||||||
|
"fagan_bradley": 24.740300,
|
||||||
|
"krishnamurti": 23.760240,
|
||||||
|
}
|
||||||
|
_J2000 = 2451545.0
|
||||||
|
_B = 3.824459e-5 # °/dobę — liniowy człon precesji (wspólny)
|
||||||
|
_C = 2.304e-13 # °/dobę² — drobne przyspieszenie (wspólne)
|
||||||
|
|
||||||
|
# nazwy zodiaków akceptowane przez API
|
||||||
|
SIDEREAL = tuple(f"sidereal_{k}" for k in _AYAN0) # sidereal_lahiri, ...
|
||||||
|
SYSTEMS = (TROPICAL, *SIDEREAL, DRACONIC)
|
||||||
|
|
||||||
|
|
||||||
|
def julian_day(dt: datetime) -> float:
|
||||||
|
"""Julian Day (UT) z momentu UTC — algorytm Meeusa (kalendarz gregoriański)."""
|
||||||
|
y, m = dt.year, dt.month
|
||||||
|
day = dt.day + (dt.hour + dt.minute / 60.0 + dt.second / 3600.0
|
||||||
|
+ dt.microsecond / 3.6e9) / 24.0
|
||||||
|
if m <= 2:
|
||||||
|
y -= 1
|
||||||
|
m += 12
|
||||||
|
a = y // 100
|
||||||
|
b = 2 - a + a // 4
|
||||||
|
return math.floor(365.25 * (y + 4716)) + math.floor(30.6001 * (m + 1)) + day + b - 1524.5
|
||||||
|
|
||||||
|
|
||||||
|
def ayanamsha(name: str, jd: float) -> float:
|
||||||
|
"""Ayanamsa [°] danej szkoły dla Julian Day (UT)."""
|
||||||
|
key = name[len("sidereal_"):] if name.startswith("sidereal_") else name
|
||||||
|
if key not in _AYAN0:
|
||||||
|
raise ValueError(f"Nieznana ayanamsa: {name!r} (dostępne: {', '.join(_AYAN0)})")
|
||||||
|
x = jd - _J2000
|
||||||
|
return _AYAN0[key] + _B * x + _C * x * x
|
||||||
|
|
||||||
|
|
||||||
|
def offset(zodiac: str, jd: float, node_lon: float | None = None) -> float:
|
||||||
|
"""Ile odjąć od długości tropikalnej, by dostać wybrany zodiak.
|
||||||
|
|
||||||
|
`node_lon` (tropikalna długość węzła wznoszącego) wymagana tylko dla draconic.
|
||||||
|
"""
|
||||||
|
if zodiac == TROPICAL:
|
||||||
|
return 0.0
|
||||||
|
if zodiac == DRACONIC:
|
||||||
|
if node_lon is None:
|
||||||
|
raise ValueError("draconic wymaga długości węzła (node_lon)")
|
||||||
|
return norm360(node_lon)
|
||||||
|
if zodiac in SIDEREAL:
|
||||||
|
return ayanamsha(zodiac, jd)
|
||||||
|
raise ValueError(f"Nieznany zodiak: {zodiac!r} (dostępne: {', '.join(SYSTEMS)})")
|
||||||
|
|
||||||
|
|
||||||
|
def apply(lon: float, off: float) -> float:
|
||||||
|
"""Długość w docelowym zodiaku."""
|
||||||
|
return norm360(lon - off)
|
||||||
|
|
||||||
|
|
||||||
|
def to_equatorial(lon: float, lat: float, eps: float) -> tuple[float, float]:
|
||||||
|
"""Ekliptyka (λ, β) → równik: (RA, deklinacja) w stopniach. Wszystko w °.
|
||||||
|
|
||||||
|
RA rośnie 0–360°; deklinacja w [−90, 90].
|
||||||
|
"""
|
||||||
|
lam, bet, e = math.radians(lon), math.radians(lat), math.radians(eps)
|
||||||
|
sin_dec = math.sin(bet) * math.cos(e) + math.cos(bet) * math.sin(e) * math.sin(lam)
|
||||||
|
dec = math.asin(max(-1.0, min(1.0, sin_dec)))
|
||||||
|
ra = math.atan2(
|
||||||
|
math.sin(lam) * math.cos(e) - math.tan(bet) * math.sin(e),
|
||||||
|
math.cos(lam),
|
||||||
|
)
|
||||||
|
return norm360(math.degrees(ra)), math.degrees(dec)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Warstwa dostawców modeli językowych (LOG-31)."""
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Kontrakt dostawcy modelu językowego (LOG-31).
|
||||||
|
|
||||||
|
Analogicznie do `EphemerisEngine` (LOG-24): prezentacja i reszta logiki nie wiedzą,
|
||||||
|
kto pisze tekst — lokalny model na naszym sprzęcie czy dostawca w chmurze.
|
||||||
|
|
||||||
|
Kluczowa własność dla bezpieczeństwa baz (LOG-32): każdy dostawca deklaruje
|
||||||
|
`leaves_lan`. Prompt niesie ORYGINALNE opisy z baz, więc interfejs musi jawnie
|
||||||
|
mówić, czy ta treść opuszcza naszą sieć — UI ma na tej podstawie ostrzegać.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Completion:
|
||||||
|
"""Wynik generowania — tekst + metryki do rozliczenia i podglądu."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
model: str
|
||||||
|
provider: str
|
||||||
|
leaves_lan: bool
|
||||||
|
usage: dict = field(default_factory=dict) # prompt_tokens / completion_tokens
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(RuntimeError):
|
||||||
|
"""Błąd wołania modelu — z komunikatem nadającym się do pokazania użytkownikowi."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
name: str = "?"
|
||||||
|
#: czy treść promptu (a więc opisy z baz) opuszcza naszą sieć
|
||||||
|
leaves_lan: bool = True
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||||
|
"""Zwraca gotowy tekst. Rzuca LLMError przy niepowodzeniu."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def health(self) -> dict:
|
||||||
|
"""Czy dostawca jest osiągalny i skonfigurowany."""
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Wybór dostawcy LLM (LOG-31) — jedyne miejsce znające konkretne implementacje.
|
||||||
|
|
||||||
|
Domyślny jest **model lokalny**: prompt niesie oryginalne opisy z baz, więc
|
||||||
|
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
|
||||||
|
przez konfigurację albo pojedyncze żądanie.
|
||||||
|
|
||||||
|
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
|
||||||
|
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
|
||||||
|
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
|
||||||
|
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
|
||||||
|
|
||||||
|
Zmienne środowiskowe:
|
||||||
|
LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
|
||||||
|
LLM_TIMEOUT sekundy (domyślnie 120)
|
||||||
|
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
|
||||||
|
|
||||||
|
<DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
|
||||||
|
LOCAL_MODEL, LOCAL_BASE_URL (klucz zwykle zbędny)
|
||||||
|
OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY
|
||||||
|
ANTHROPIC_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
Klucze WYŁĄCZNIE z sekretu — nigdy w repo, w UI ani w logach.
|
||||||
|
|
||||||
|
Zgodność wstecz: wspólne `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` nadal działają,
|
||||||
|
ale stosują się TYLKO do dostawcy domyślnego (LLM_PROVIDER) — czyli konfiguracja
|
||||||
|
instalacji jednodostawcowej zostaje nietknięta, a pozostali dostawcy jej nie dziedziczą.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.llm.base import LLMError, LLMProvider
|
||||||
|
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||||
|
|
||||||
|
LOCAL = "local"
|
||||||
|
OPENAI = "openai"
|
||||||
|
ANTHROPIC = "anthropic"
|
||||||
|
PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
|
||||||
|
|
||||||
|
_DEFAULT_MODEL = {
|
||||||
|
LOCAL: "llama3.1:8b",
|
||||||
|
OPENAI: "gpt-4o-mini",
|
||||||
|
ANTHROPIC: "claude-sonnet-5",
|
||||||
|
}
|
||||||
|
_DEFAULT_URL = {
|
||||||
|
# Ollama i vLLM wystawiają zgodne API pod /v1
|
||||||
|
LOCAL: "http://localhost:11434/v1",
|
||||||
|
OPENAI: "https://api.openai.com/v1",
|
||||||
|
ANTHROPIC: "https://api.anthropic.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_provider_name() -> str:
|
||||||
|
return os.getenv("LLM_PROVIDER", LOCAL).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def max_tokens() -> int:
|
||||||
|
return int(os.getenv("LLM_MAX_TOKENS", "2000"))
|
||||||
|
|
||||||
|
|
||||||
|
def timeout() -> float:
|
||||||
|
return float(os.getenv("LLM_TIMEOUT", "120"))
|
||||||
|
|
||||||
|
|
||||||
|
def setting(provider: str, suffix: str, fallback: str = "") -> str:
|
||||||
|
"""Ustawienie dostawcy: <DOSTAWCA>_<SUFIKS> → LLM_<SUFIKS> → wbudowana domyślna.
|
||||||
|
|
||||||
|
Wspólne `LLM_*` stosuje się WYŁĄCZNIE do dostawcy domyślnego — inaczej adres
|
||||||
|
lokalnego modelu przejąłby żądania do chmury (i odwrotnie).
|
||||||
|
"""
|
||||||
|
specific = os.getenv(f"{provider.upper()}_{suffix}")
|
||||||
|
if specific:
|
||||||
|
return specific
|
||||||
|
if provider == default_provider_name():
|
||||||
|
generic = os.getenv(f"LLM_{suffix}")
|
||||||
|
if generic:
|
||||||
|
return generic
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def build_provider(name: str | None = None) -> LLMProvider:
|
||||||
|
name = (name or default_provider_name()).lower()
|
||||||
|
if name not in PROVIDERS:
|
||||||
|
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
||||||
|
|
||||||
|
model = setting(name, "MODEL", _DEFAULT_MODEL[name])
|
||||||
|
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
|
||||||
|
api_key = setting(name, "API_KEY")
|
||||||
|
|
||||||
|
if name in (OPENAI, ANTHROPIC) and not api_key:
|
||||||
|
raise LLMError(
|
||||||
|
f"Brak klucza dla dostawcy {name} — ustaw {name.upper()}_API_KEY "
|
||||||
|
f"(z sekretu). Model lokalny klucza nie wymaga."
|
||||||
|
)
|
||||||
|
if name == ANTHROPIC:
|
||||||
|
return AnthropicProvider(base_url, model, api_key, timeout())
|
||||||
|
if name == OPENAI:
|
||||||
|
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
|
||||||
|
leaves_lan=True)
|
||||||
|
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
|
||||||
|
return ChatCompletionsProvider(LOCAL, base_url, model, api_key, timeout(),
|
||||||
|
leaves_lan=False)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Implementacje dostawców LLM (LOG-31) — na samym httpx, bez SDK.
|
||||||
|
|
||||||
|
Świadomie bez bibliotek `openai` / `anthropic`: lokalny serwer modelu (Ollama,
|
||||||
|
vLLM, llama.cpp) i OpenAI mówią **tym samym** protokołem `/chat/completions`,
|
||||||
|
więc jedna implementacja obsługuje oba — różni je tylko adres i klucz. Anthropic
|
||||||
|
ma własny kształt `/v1/messages`, stąd druga klasa. Mniej zależności, mniej
|
||||||
|
powierzchni ataku, pełna kontrola nad tym, co wychodzi z sieci.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.llm.base import Completion, LLMError, LLMProvider
|
||||||
|
|
||||||
|
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||||
|
MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
|
||||||
|
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
|
||||||
|
last: Exception | None = None
|
||||||
|
for attempt in range(MAX_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
r = client.post(url, json=payload, headers=headers)
|
||||||
|
if r.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise LLMError(f"Model odpowiedział błędem {r.status_code}: {r.text[:300]}")
|
||||||
|
return r.json()
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
last = e
|
||||||
|
if attempt < MAX_ATTEMPTS - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise LLMError(
|
||||||
|
f"Model nie odpowiedział w czasie {timeout:.0f}s. Dłuższe horoskopy "
|
||||||
|
f"wymagają większego LLM_TIMEOUT albo mniejszego budżetu promptu."
|
||||||
|
) from e
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
last = e
|
||||||
|
if attempt < MAX_ATTEMPTS - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise LLMError(f"Nie udało się połączyć z modelem: {e}") from e
|
||||||
|
raise LLMError(f"Nie udało się wywołać modelu: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionsProvider(LLMProvider):
|
||||||
|
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
|
||||||
|
timeout: float = 120.0, leaves_lan: bool = True) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.leaves_lan = leaves_lan
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
h = {"Content-Type": "application/json"}
|
||||||
|
if self.api_key:
|
||||||
|
h["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
return h
|
||||||
|
|
||||||
|
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||||
|
data = _post_with_retry(
|
||||||
|
f"{self.base_url}/chat/completions", self._headers(),
|
||||||
|
{
|
||||||
|
"model": self.model,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
},
|
||||||
|
self.timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
text = data["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||||
|
return Completion(
|
||||||
|
text=text, model=data.get("model", self.model), provider=self.name,
|
||||||
|
leaves_lan=self.leaves_lan, usage=data.get("usage") or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=min(self.timeout, 10.0)) as client:
|
||||||
|
r = client.get(f"{self.base_url}/models", headers=self._headers())
|
||||||
|
info["status"] = "ok" if r.status_code < 400 else f"http {r.status_code}"
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
info["status"] = f"down: {e}"
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicProvider(LLMProvider):
|
||||||
|
"""Protokół Anthropic `/v1/messages`."""
|
||||||
|
|
||||||
|
leaves_lan = True
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, model: str, api_key: str = "",
|
||||||
|
timeout: float = 120.0) -> None:
|
||||||
|
self.name = "anthropic"
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||||
|
if not self.api_key:
|
||||||
|
raise LLMError("Brak LLM_API_KEY — dostawca anthropic wymaga klucza.")
|
||||||
|
data = _post_with_retry(
|
||||||
|
f"{self.base_url}/v1/messages",
|
||||||
|
{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": self.api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": self.model,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
},
|
||||||
|
self.timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text")
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||||
|
return Completion(
|
||||||
|
text=text, model=data.get("model", self.model), provider=self.name,
|
||||||
|
leaves_lan=True, usage=data.get("usage") or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
return {
|
||||||
|
"provider": self.name, "model": self.model, "leaves_lan": True,
|
||||||
|
"status": "ok (klucz ustawiony)" if self.api_key else "brak LLM_API_KEY",
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""Warstwa LOGICZNA — usługa HTTP.
|
||||||
|
|
||||||
|
W górę: udostępnia API dla warstwy prezentacji.
|
||||||
|
W dół: woła warstwę bazodanową (DataClient).
|
||||||
|
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
from app.clients.data_client import DataClient
|
||||||
|
from app.models import QueryRequest, QueryResponse
|
||||||
|
from app.service import QueryService
|
||||||
|
|
||||||
|
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||||
|
service = QueryService()
|
||||||
|
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
||||||
|
|
||||||
|
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
||||||
|
_engine = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
from app.engine.factory import build_engine
|
||||||
|
|
||||||
|
_engine = build_engine()
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
class PositionsRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment w UTC (świadomy strefy)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
objects: list[str] | None = None
|
||||||
|
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||||
|
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
||||||
|
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/query", response_model=QueryResponse)
|
||||||
|
def query(req: QueryRequest) -> QueryResponse:
|
||||||
|
try:
|
||||||
|
return service.handle(req)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/positions")
|
||||||
|
def chart_positions(req: PositionsRequest) -> dict:
|
||||||
|
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
|
||||||
|
opcjonalnie stacje planet (LOG-03, stations=true)."""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
try:
|
||||||
|
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
if req.stations:
|
||||||
|
from app.engine.stations import find_stations
|
||||||
|
|
||||||
|
for p in chart["positions"]:
|
||||||
|
st = find_stations(engine, moment, p["name"])
|
||||||
|
if st:
|
||||||
|
p["stations"] = st
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/compare")
|
||||||
|
def chart_compare(req: PositionsRequest) -> dict:
|
||||||
|
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
|
||||||
|
|
||||||
|
Wymaga skonfigurowanego ENGINE_SWISSEPH_URL (silnik B). W przeciwnym razie
|
||||||
|
zwraca informację, że porównanie jest niedostępne.
|
||||||
|
"""
|
||||||
|
from app.engine.compare import compare_engines
|
||||||
|
from app.engine.factory import build_engine
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
try:
|
||||||
|
report = compare_engines(build_engine("own"), build_engine("swisseph"), moment)
|
||||||
|
except (RuntimeError, httpx.HTTPError) as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Silnik B niedostępny: {e}")
|
||||||
|
return report.summary()
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRequest(BaseModel):
|
||||||
|
when_utc: datetime
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
limit: int = 5000
|
||||||
|
group: bool = False # grupowanie identycznych opisów
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/report")
|
||||||
|
def chart_report(req: ReportRequest) -> dict:
|
||||||
|
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
|
||||||
|
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.significators import build_report
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
chart = build_chart(engine, moment) # pozycje z domami + aspekty
|
||||||
|
try:
|
||||||
|
report = build_report(
|
||||||
|
chart["positions"], DataClient(),
|
||||||
|
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
||||||
|
return {"engine": engine.name, **report}
|
||||||
|
|
||||||
|
|
||||||
|
class PromptRequest(BaseModel):
|
||||||
|
"""Wejście generatora promptu (LOG-29/30)."""
|
||||||
|
profile: str = "natal" # natal | period
|
||||||
|
when_utc: datetime
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
budget: str = "medium" # concise | medium | extensive
|
||||||
|
limit: int = 5000
|
||||||
|
# tylko dla profilu period:
|
||||||
|
from_date: str | None = None
|
||||||
|
to_date: str | None = None
|
||||||
|
techniques: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/prompt")
|
||||||
|
def chart_prompt(req: PromptRequest) -> dict:
|
||||||
|
"""Gotowy prompt do LLM z naszych wyliczeń (LOG-29) z budżetowaniem (LOG-30).
|
||||||
|
|
||||||
|
profile=natal → horoskop urodzeniowy (ekran Interpretacje)
|
||||||
|
profile=period → horoskop na wybrany okres (ekran Kalendarz)
|
||||||
|
|
||||||
|
Nie woła żadnego modelu — zwraca sam prompt i statystyki redukcji, żeby dało się
|
||||||
|
go obejrzeć i skopiować. Wysyłkę do modelu doda LOG-31.
|
||||||
|
"""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.prompt import build_natal_prompt, build_period_prompt
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
chart = build_chart(engine, moment)
|
||||||
|
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
data_error = None
|
||||||
|
|
||||||
|
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
|
||||||
|
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
|
||||||
|
try:
|
||||||
|
if req.profile == "natal":
|
||||||
|
from app.significators import build_report
|
||||||
|
|
||||||
|
report: dict = {"objects": []}
|
||||||
|
try:
|
||||||
|
report = build_report(
|
||||||
|
chart["positions"], DataClient(),
|
||||||
|
aspects=chart.get("aspects"), per_object_limit=req.limit,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
data_error = f"Warstwa danych niedostępna: {e}"
|
||||||
|
out = build_natal_prompt(chart, report, req.budget, label)
|
||||||
|
|
||||||
|
elif req.profile == "period":
|
||||||
|
if not (req.from_date and req.to_date):
|
||||||
|
raise HTTPException(422, "profile=period wymaga from_date i to_date")
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.timeline import build_timeline
|
||||||
|
from app.significators import interpret_events
|
||||||
|
|
||||||
|
ramc, eps = engine.sidereal(moment)
|
||||||
|
points = {"Asc": H.compute_asc(ramc, eps, moment.lat), "MC": H.compute_mc(ramc, eps)}
|
||||||
|
for p in engine.positions(moment):
|
||||||
|
points[p.name] = p.longitude
|
||||||
|
events = build_timeline(engine, moment, points, req.from_date, req.to_date,
|
||||||
|
req.techniques)
|
||||||
|
try:
|
||||||
|
interpret_events(events, DataClient())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
||||||
|
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
||||||
|
req.budget, label)
|
||||||
|
else:
|
||||||
|
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
||||||
|
except ValueError as e: # nieznany budżet
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
|
||||||
|
out["engine"] = engine.name
|
||||||
|
if data_error:
|
||||||
|
out["data_error"] = data_error
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class HoroscopeRequest(PromptRequest):
|
||||||
|
"""Jak PromptRequest + wybór dostawcy modelu (LOG-31)."""
|
||||||
|
provider: str | None = None # local (dom.) | openai | anthropic
|
||||||
|
max_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/horoscope")
|
||||||
|
def chart_horoscope(req: HoroscopeRequest) -> dict:
|
||||||
|
"""Napisany horoskop (LOG-31): prompt z LOG-29/30 → model → gotowy tekst.
|
||||||
|
|
||||||
|
Zwraca TAKŻE użyty prompt — również gdy wywołanie modelu padnie — żeby dało się
|
||||||
|
go obejrzeć i użyć ręcznie. `leaves_lan` mówi, czy treść baz opuściła naszą sieć
|
||||||
|
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
||||||
|
"""
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
from app.llm.factory import build_provider, max_tokens
|
||||||
|
|
||||||
|
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
||||||
|
try:
|
||||||
|
provider = build_provider(req.provider)
|
||||||
|
result = provider.generate(out["prompt"], req.max_tokens or max_tokens())
|
||||||
|
except LLMError as e:
|
||||||
|
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
||||||
|
out["llm_error"] = str(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
out.update(
|
||||||
|
horoscope=result.text,
|
||||||
|
provider=result.provider,
|
||||||
|
model=result.model,
|
||||||
|
leaves_lan=result.leaves_lan,
|
||||||
|
usage=result.usage,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/llm/health")
|
||||||
|
def llm_health(provider: str | None = None) -> dict:
|
||||||
|
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
from app.llm.factory import build_provider
|
||||||
|
|
||||||
|
try:
|
||||||
|
return build_provider(provider).health()
|
||||||
|
except LLMError as e:
|
||||||
|
return {"status": f"blad konfiguracji: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
class ProfectionsRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
start_age: int = 0
|
||||||
|
count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/profections")
|
||||||
|
def chart_profections(req: ProfectionsRequest) -> dict:
|
||||||
|
"""Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo)."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.profections import profection_rows
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
points = {
|
||||||
|
"Asc": H.compute_asc(ramc, eps, natal.lat),
|
||||||
|
"MC": H.compute_mc(ramc, eps),
|
||||||
|
}
|
||||||
|
for p in engine.positions(natal, ["Sun", "Moon"]):
|
||||||
|
points[p.name] = p.longitude
|
||||||
|
rows = profection_rows(points, req.when_utc, req.start_age, req.count)
|
||||||
|
return {"engine": engine.name, "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
kind: str = "solar" # solar | lunar
|
||||||
|
around: datetime | None = None # data, wokół której szukać powrotu
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/return")
|
||||||
|
def chart_return(req: ReturnRequest) -> dict:
|
||||||
|
"""Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment."""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.returns import find_return
|
||||||
|
|
||||||
|
if req.kind not in ("solar", "lunar"):
|
||||||
|
raise HTTPException(status_code=422, detail="kind: solar albo lunar")
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
around = req.around or req.when_utc
|
||||||
|
hit = find_return(engine, req.kind, natal, around)
|
||||||
|
if hit is None:
|
||||||
|
raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu")
|
||||||
|
chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon))
|
||||||
|
return {"engine": engine.name, "kind": req.kind,
|
||||||
|
"return_utc": hit.isoformat(), **chart}
|
||||||
|
|
||||||
|
|
||||||
|
class FirdariaRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/firdaria")
|
||||||
|
def chart_firdaria(req: FirdariaRequest) -> dict:
|
||||||
|
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
|
||||||
|
sun = engine.positions(natal, ["Sun"])[0].longitude
|
||||||
|
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
from_date: str # zakres: YYYY-MM-DD
|
||||||
|
to_date: str
|
||||||
|
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
||||||
|
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/timeline")
|
||||||
|
def chart_timeline(req: TimelineRequest) -> dict:
|
||||||
|
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
|
||||||
|
|
||||||
|
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
|
||||||
|
"""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.timeline import build_timeline
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
|
||||||
|
for p in engine.positions(natal):
|
||||||
|
points[p.name] = p.longitude
|
||||||
|
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
||||||
|
|
||||||
|
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
|
||||||
|
if req.interpret:
|
||||||
|
from app.significators import interpret_events
|
||||||
|
try:
|
||||||
|
interpret_events(events, DataClient())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
out["data_error"] = f"Warstwa danych niedostępna: {e}"
|
||||||
|
out.update(count=len(events), events=events)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
info = {"status": "ok", "layer": "logic"}
|
||||||
|
try:
|
||||||
|
info["data_layer"] = DataClient().health()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
info["data_layer"] = {"status": "down", "error": str(e)}
|
||||||
|
return info
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""Generator promptów do LLM (LOG-29) + budżetowanie rozmiaru (LOG-30).
|
||||||
|
|
||||||
|
Składa z naszych wyliczeń gotowe, profesjonalne polecenie po polsku:
|
||||||
|
- profil **natal** — horoskop urodzeniowy (na bazie /chart/report),
|
||||||
|
- profil **period** — horoskop na wybrany okres (na bazie /chart/timeline).
|
||||||
|
|
||||||
|
Zasada naczelna promptu: model ma pisać WYŁĄCZNIE na podstawie dostarczonych danych
|
||||||
|
i przy każdej tezie wskazać konkretny sygnifikator, z którego ona wynika. To odróżnia
|
||||||
|
wynik od ogólnikowej wróżby i pozwala go zweryfikować.
|
||||||
|
|
||||||
|
Budżetowanie (LOG-30) — kolejność redukcji:
|
||||||
|
1. deduplikacja (ten sam sygnifikator ORAZ ten sam opis),
|
||||||
|
2. grupowanie identycznych opisów z licznikiem wystąpień,
|
||||||
|
3. sortowanie malejąco wg punktacji siły (LOG-21),
|
||||||
|
4. obcięcie ogona do budżetu — jednostką obcięcia jest CAŁE wskazanie,
|
||||||
|
5. skracanie nadmiernie długich opisów (z jawnym oznaczeniem).
|
||||||
|
Zawsze raportujemy, ile wskazań weszło i ile pominięto — użytkownik ma wiedzieć,
|
||||||
|
czego brakuje, i móc zwiększyć budżet.
|
||||||
|
|
||||||
|
Generowanie jest deterministyczne: ten sam horoskop + ten sam budżet = ten sam prompt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# budżety w znakach (całego promptu); dobrane pod wklejanie do ChatGPT/Claude
|
||||||
|
BUDGETS: dict[str, int] = {
|
||||||
|
"concise": 4_000,
|
||||||
|
"medium": 12_000,
|
||||||
|
"extensive": 30_000,
|
||||||
|
}
|
||||||
|
DEFAULT_BUDGET = "medium"
|
||||||
|
|
||||||
|
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
|
||||||
|
CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI
|
||||||
|
|
||||||
|
DISCLAIMER = (
|
||||||
|
"Zakończ krótką notą: treść jest interpretacją astrologiczną i nie stanowi porady "
|
||||||
|
"medycznej, prawnej ani finansowej."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", (text or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def est_tokens(text: str) -> int:
|
||||||
|
return int(len(text) / CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten(text: str, limit: int = MAX_EFFECT_CHARS) -> tuple[str, bool]:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text, False
|
||||||
|
return text[:limit].rstrip() + " […skrócono]", True
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- wskazania
|
||||||
|
|
||||||
|
def natal_indications(report: dict) -> list[dict]:
|
||||||
|
"""Płaska lista wskazań z raportu natalnego: obiekt + faseta + opis + waga."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for obj in report.get("objects") or []:
|
||||||
|
name = obj.get("object")
|
||||||
|
for facet in obj.get("facets") or []:
|
||||||
|
score = float(facet.get("score") or 0.0)
|
||||||
|
for s in facet.get("samples") or []:
|
||||||
|
out.append({
|
||||||
|
"context": f"{name} — {facet.get('label')}",
|
||||||
|
"sort_key": (name or "", str(facet.get("label") or "")),
|
||||||
|
"score": score,
|
||||||
|
"significator": s.get("expanded") or s.get("significator") or "",
|
||||||
|
"effect": s.get("effect") or "",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def period_indications(events: list[dict]) -> list[dict]:
|
||||||
|
"""Wskazania z osi czasu — waga rośnie z liczbą trafień w bazie."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for ev in events or []:
|
||||||
|
head = f"{ev.get('technique')} · {ev.get('significator')} · {ev.get('exact')}"
|
||||||
|
total = float(ev.get("interpretations_count") or 0)
|
||||||
|
for s in ev.get("interpretations") or []:
|
||||||
|
out.append({
|
||||||
|
"context": head,
|
||||||
|
"sort_key": (str(ev.get("exact") or ""), str(ev.get("technique") or "")),
|
||||||
|
"score": total,
|
||||||
|
"significator": s.get("expanded") or s.get("significator") or "",
|
||||||
|
"effect": s.get("effect") or "",
|
||||||
|
"date": ev.get("exact"),
|
||||||
|
"start": ev.get("start"),
|
||||||
|
"end": ev.get("end"),
|
||||||
|
"technique": ev.get("technique"),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reduce_indications(items: list[dict], budget_chars: int) -> tuple[list[dict], dict]:
|
||||||
|
"""Dedup → grupowanie → sortowanie wg wagi → obcięcie do budżetu.
|
||||||
|
|
||||||
|
Zwraca (wybrane wskazania, statystyki). Jednostką obcięcia jest całe wskazanie.
|
||||||
|
"""
|
||||||
|
# 1+2: dedup i grupowanie identycznych (kontekst, opis) z licznikiem
|
||||||
|
grouped: dict[tuple[str, str], dict] = {}
|
||||||
|
order: list[tuple[str, str]] = []
|
||||||
|
for it in items:
|
||||||
|
key = (_norm(it["context"]), _norm(it["effect"]))
|
||||||
|
g = grouped.get(key)
|
||||||
|
if g is None:
|
||||||
|
g = {**it, "count": 0, "significators": []}
|
||||||
|
grouped[key] = g
|
||||||
|
order.append(key)
|
||||||
|
g["count"] += 1
|
||||||
|
if it["significator"] not in g["significators"]:
|
||||||
|
g["significators"].append(it["significator"])
|
||||||
|
merged = [grouped[k] for k in order]
|
||||||
|
deduped = len(items) - len(merged)
|
||||||
|
|
||||||
|
# 3: sortowanie malejąco wg wagi; remis rozstrzygany deterministycznie
|
||||||
|
merged.sort(key=lambda g: (-g["score"], -g["count"], g["sort_key"], g["significator"]))
|
||||||
|
|
||||||
|
# 5 (przed obcięciem, bo wpływa na rozmiar): skracanie długich opisów
|
||||||
|
shortened = 0
|
||||||
|
for g in merged:
|
||||||
|
g["effect"], was = _shorten(g["effect"])
|
||||||
|
shortened += 1 if was else 0
|
||||||
|
|
||||||
|
# 4: obcięcie ogona do budżetu — cała pozycja albo nic
|
||||||
|
chosen: list[dict] = []
|
||||||
|
used = 0
|
||||||
|
for g in merged:
|
||||||
|
cost = len(_render_indication(g)) + 1
|
||||||
|
if used + cost > budget_chars and chosen:
|
||||||
|
break
|
||||||
|
chosen.append(g)
|
||||||
|
used += cost
|
||||||
|
|
||||||
|
omitted = len(merged) - len(chosen)
|
||||||
|
stats = {
|
||||||
|
"source_rows": len(items),
|
||||||
|
"after_grouping": len(merged),
|
||||||
|
"deduplicated": deduped,
|
||||||
|
"included": len(chosen),
|
||||||
|
"omitted": omitted,
|
||||||
|
"shortened": shortened,
|
||||||
|
"min_score_included": round(chosen[-1]["score"], 3) if chosen else None,
|
||||||
|
"max_score_omitted": round(merged[len(chosen)]["score"], 3) if omitted else None,
|
||||||
|
}
|
||||||
|
return chosen, stats
|
||||||
|
|
||||||
|
|
||||||
|
def _render_indication(g: dict) -> str:
|
||||||
|
times = f" (×{g['count']})" if g.get("count", 1) > 1 else ""
|
||||||
|
sig = g.get("significator") or ""
|
||||||
|
return f"- {sig} → {g.get('effect')}{times}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_grouped(chosen: list[dict]) -> str:
|
||||||
|
"""Renderuje wskazania pogrupowane po kontekście, zachowując kolejność wagi."""
|
||||||
|
blocks: list[str] = []
|
||||||
|
seen: dict[str, list[dict]] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for g in chosen:
|
||||||
|
ctx = g["context"]
|
||||||
|
if ctx not in seen:
|
||||||
|
seen[ctx] = []
|
||||||
|
order.append(ctx)
|
||||||
|
seen[ctx].append(g)
|
||||||
|
for ctx in order:
|
||||||
|
rows = seen[ctx]
|
||||||
|
blocks.append(f"## {ctx} [waga {rows[0]['score']:.2f}]")
|
||||||
|
blocks.extend(_render_indication(g) for g in rows)
|
||||||
|
blocks.append("")
|
||||||
|
return "\n".join(blocks).rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- sekcje danych
|
||||||
|
|
||||||
|
def _chart_section(chart: dict, moment_label: str | None) -> str:
|
||||||
|
lines: list[str] = ["# DANE HOROSKOPU"]
|
||||||
|
meta = []
|
||||||
|
if moment_label:
|
||||||
|
meta.append(f"moment: {moment_label}")
|
||||||
|
if chart.get("zodiac"):
|
||||||
|
z = chart["zodiac"]
|
||||||
|
if chart.get("ayanamsha") is not None:
|
||||||
|
z += f" (ayanamsa {chart['ayanamsha']:.4f}°)"
|
||||||
|
meta.append(f"zodiak: {z}")
|
||||||
|
if chart.get("house_system"):
|
||||||
|
meta.append(f"system domów: {chart['house_system']}")
|
||||||
|
if chart.get("sect"):
|
||||||
|
meta.append(f"sekta: {'dzienna' if chart['sect'] == 'day' else 'nocna'}")
|
||||||
|
if meta:
|
||||||
|
lines.append(" · ".join(meta))
|
||||||
|
|
||||||
|
if chart.get("positions"):
|
||||||
|
lines.append("\n## Pozycje")
|
||||||
|
for p in chart["positions"]:
|
||||||
|
house = f"dom {p['house']}" if p.get("house") else "—"
|
||||||
|
lines.append(f"{p['name']:<12} {p.get('in_sign',''):<16} {house:<8} {p.get('direction','')}")
|
||||||
|
|
||||||
|
angles = chart.get("angles") or {}
|
||||||
|
if angles:
|
||||||
|
lines.append("\n## Osie")
|
||||||
|
for key in ("Asc", "MC", "Dsc", "IC"):
|
||||||
|
a = angles.get(key)
|
||||||
|
if a:
|
||||||
|
lines.append(f"{a['name']:<5} {a.get('in_sign','')}")
|
||||||
|
|
||||||
|
if chart.get("lots"):
|
||||||
|
lines.append("\n## Lots (punkty arabskie)")
|
||||||
|
for lot in chart["lots"]:
|
||||||
|
lines.append(
|
||||||
|
f"{lot['name']:<10} {lot.get('in_sign',''):<16} dom {lot.get('house','—')}"
|
||||||
|
f" ({lot.get('formula','')})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if chart.get("aspects"):
|
||||||
|
lines.append("\n## Aspekty (orb; A = aplikacyjny, S = separacyjny)")
|
||||||
|
for a in chart["aspects"]:
|
||||||
|
mark = a.get("as") or ""
|
||||||
|
lines.append(
|
||||||
|
f"{a['obj1']} {a['aspect']} {a['obj2']} orb {a.get('orb', 0):.2f}° {mark}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _indications_section(chosen: list[dict], stats: dict) -> str:
|
||||||
|
if not chosen:
|
||||||
|
return (
|
||||||
|
"# WSKAZANIA Z BAZ\n"
|
||||||
|
"(brak trafień w bazach dla tego horoskopu — oprzyj interpretację wyłącznie "
|
||||||
|
"na danych horoskopu powyżej)"
|
||||||
|
)
|
||||||
|
head = [
|
||||||
|
"# WSKAZANIA Z BAZ INTERPRETACYJNYCH",
|
||||||
|
"Wskazania dopasowane do tego horoskopu, uporządkowane od najsilniejszych.",
|
||||||
|
"Zapis: `- sygnifikator → opis (×ile razy wystąpiło w bazach)`.",
|
||||||
|
]
|
||||||
|
if stats.get("omitted"):
|
||||||
|
head.append(
|
||||||
|
f"UWAGA: pokazano {stats['included']} najsilniejszych wskazań, pominięto "
|
||||||
|
f"{stats['omitted']} słabszych (limit długości)."
|
||||||
|
)
|
||||||
|
return "\n".join(head) + "\n\n" + _render_grouped(chosen)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ prompty
|
||||||
|
|
||||||
|
_NATAL_TASK = """# ZADANIE
|
||||||
|
Jesteś doświadczonym astrologiem. Napisz profesjonalną interpretację HOROSKOPU
|
||||||
|
URODZENIOWEGO po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||||
|
|
||||||
|
_PERIOD_TASK = """# ZADANIE
|
||||||
|
Jesteś doświadczonym astrologiem. Napisz profesjonalną prognozę astrologiczną
|
||||||
|
NA WYBRANY OKRES po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||||
|
|
||||||
|
_NATAL_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||||
|
1. Struktura: (a) portret ogólny, (b) temperament i sekta, (c) obszary życia według domów,
|
||||||
|
(d) napięcia i wyzwania, (e) zasoby i mocne strony, (f) zwięzłe podsumowanie.
|
||||||
|
2. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie, np. „(Moon in 12th house)".
|
||||||
|
Teza bez wskazania jest niedopuszczalna.
|
||||||
|
3. Nie dodawaj twierdzeń, których nie da się wywieść z powyższych danych. Nie zmyślaj
|
||||||
|
pozycji, aspektów ani wskazań; nie korzystaj z wiedzy spoza tego promptu.
|
||||||
|
4. Gdy wskazania są sprzeczne, powiedz to wprost i wskaż obie strony, zamiast wybierać jedną.
|
||||||
|
5. Wagę wskazania traktuj jako siłę świadectwa — mocniejsze mają pierwszeństwo w syntezie.
|
||||||
|
6. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||||
|
|
||||||
|
_PERIOD_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||||
|
1. Uporządkuj prognozę CHRONOLOGICZNIE; przy każdym okresie podaj daty (start / dokładna / koniec).
|
||||||
|
2. Dla każdej daty napisz, która technika ją wyznacza (profekcja, solariusz, dyrekcja solar-arc,
|
||||||
|
Firdaria) i co z niej wynika.
|
||||||
|
3. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie. Teza bez wskazania jest
|
||||||
|
niedopuszczalna.
|
||||||
|
4. Nie zmyślaj dat ani zdarzeń spoza podanych. Nie korzystaj z wiedzy spoza tego promptu.
|
||||||
|
5. Rozróżniaj okresy o mocnym świadectwie (wysoka waga, kilka technik zbieżnych w czasie)
|
||||||
|
od słabych — i powiedz wprost, które są które.
|
||||||
|
6. Na końcu dodaj krótkie zestawienie: najważniejsze okresy w kolejności ważności.
|
||||||
|
7. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str:
|
||||||
|
return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"])
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_chars(budget: str) -> int:
|
||||||
|
if budget not in BUDGETS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
|
||||||
|
)
|
||||||
|
return BUDGETS[budget]
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) -> dict:
|
||||||
|
stats = {
|
||||||
|
**stats,
|
||||||
|
"profile": profile,
|
||||||
|
"budget": budget,
|
||||||
|
"limit_chars": limit,
|
||||||
|
"chars": len(prompt),
|
||||||
|
"est_tokens": est_tokens(prompt),
|
||||||
|
}
|
||||||
|
return {"profile": profile, "prompt": prompt, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
|
||||||
|
moment_label: str | None = None) -> dict:
|
||||||
|
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
|
||||||
|
limit = _budget_chars(budget)
|
||||||
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
|
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
|
||||||
|
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
|
||||||
|
prompt = _assemble(_NATAL_TASK, chart_sec, _indications_section(chosen, stats), _NATAL_OUTPUT)
|
||||||
|
return _finish(prompt, budget, limit, stats, "natal")
|
||||||
|
|
||||||
|
|
||||||
|
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
|
||||||
|
budget: str = DEFAULT_BUDGET, moment_label: str | None = None) -> dict:
|
||||||
|
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
|
||||||
|
limit = _budget_chars(budget)
|
||||||
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
|
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
|
||||||
|
|
||||||
|
ev_lines = ["# OŚ CZASU (techniki predykcyjne)",
|
||||||
|
"Zapis: technika · sygnifikator · start → dokładna → koniec."]
|
||||||
|
for ev in events or []:
|
||||||
|
ev_lines.append(
|
||||||
|
f"- {ev.get('technique')} · {ev.get('significator')} · "
|
||||||
|
f"{ev.get('start')} → {ev.get('exact')} → {ev.get('end')}"
|
||||||
|
)
|
||||||
|
events_sec = "\n".join(ev_lines)
|
||||||
|
|
||||||
|
fixed = len(task) + len(chart_sec) + len(events_sec) + len(_PERIOD_OUTPUT) + len(DISCLAIMER) + 200
|
||||||
|
chosen, stats = reduce_indications(period_indications(events), max(limit - fixed, 500))
|
||||||
|
body = events_sec + "\n\n" + _indications_section(chosen, stats)
|
||||||
|
prompt = _assemble(task, chart_sec, body, _PERIOD_OUTPUT)
|
||||||
|
stats["events"] = len(events or [])
|
||||||
|
return _finish(prompt, budget, limit, stats, "period")
|
||||||
@@ -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 ~0–12. 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)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Uwierzytelnianie międzywarstwowe (LOG-32).
|
||||||
|
|
||||||
|
Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w
|
||||||
|
prezentacji nie wystarczy — bez tego kontrolera wystarczyłoby uderzyć w logikę
|
||||||
|
z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
|
||||||
|
musi go przynieść w nagłówku X-Astrololo-Token.
|
||||||
|
|
||||||
|
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
|
||||||
|
starcie leci ostrzeżenie.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.security")
|
||||||
|
|
||||||
|
HEADER = "X-Astrololo-Token"
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
def token() -> str:
|
||||||
|
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
|
||||||
|
return os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(token())
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, layer: str) -> None:
|
||||||
|
if not enabled():
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
|
||||||
|
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _guard(request: Request, call_next):
|
||||||
|
if request.url.path in PUBLIC_PATHS or not enabled():
|
||||||
|
return await call_next(request)
|
||||||
|
got = request.headers.get(HEADER, "")
|
||||||
|
if not secrets.compare_digest(got, token()):
|
||||||
|
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
|
||||||
|
status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
@@ -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"),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Most: policzony horoskop → tokeny sygnifikatorów → wyszukiwanie w bazie.
|
||||||
|
|
||||||
|
Zalążek LOG-15/16: z pozycji obiektów (wraz z domami) generujemy tokeny w składni
|
||||||
|
bazy i wyszukujemy pasujące rekordy. Dla każdego obiektu tworzymy kilka **faset**:
|
||||||
|
- „w znaku" — planeta + token znaku (`[Su` + `[Tau`)
|
||||||
|
- „w domu" — planeta + token domu (`[Su` + `11th H.`)
|
||||||
|
Aspekty (`[conj`,`[sq`,`[opp`) wymagają policzenia aspektów (LOG-06) — na później.
|
||||||
|
|
||||||
|
Format skrótów odczytany z realnej bazy: planety `[Su`,`[Mo`,…; znaki
|
||||||
|
`[Ari`,`[Tau`,…; domy `12th H.`; np. `[Sa [conj [Su in 6th H.`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from app.abbreviations import expand
|
||||||
|
from app.engine.formats import SIGN_ABBR, SIGNS
|
||||||
|
from app.scoring import score_facet
|
||||||
|
|
||||||
|
PLANET_ABBR = {
|
||||||
|
"Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma",
|
||||||
|
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
|
||||||
|
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
|
||||||
|
"North Node": "NN", "South Node": "SN", "Lilith": "Lilith",
|
||||||
|
# Lots — w bazie Fortuna występuje jako [PF (Part of Fortune)
|
||||||
|
"Fortune": "PF", "Spirit": "Spirit",
|
||||||
|
}
|
||||||
|
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
|
||||||
|
|
||||||
|
|
||||||
|
class DataSource(Protocol):
|
||||||
|
def search(
|
||||||
|
self, key: str, value: str, exact: bool, limit: int, fields: list[str] | None = None
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _effect(row: dict) -> str:
|
||||||
|
for col in ("actioneffect", "topicresult", "bodypart"):
|
||||||
|
v = row.get(col)
|
||||||
|
if v and str(v).strip().lower() not in ("", "nan"):
|
||||||
|
return str(v).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noise(sig: str, effect: str) -> bool:
|
||||||
|
s = sig.strip().lower()
|
||||||
|
e = effect.strip().lower()
|
||||||
|
return (
|
||||||
|
e in ("", "nan", "x", "x?", "?", "-") # efekt pusty/zastępczy
|
||||||
|
or s.startswith("significator") # wiersz-legenda/nagłówek
|
||||||
|
or "header" in s
|
||||||
|
or s.startswith("*") # *MARKER / *header
|
||||||
|
or s in ("x", "x?", "nan") # znacznik „pomiń rekord"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ordinal(n: int) -> str:
|
||||||
|
if 10 <= n % 100 <= 20:
|
||||||
|
suffix = "th"
|
||||||
|
else:
|
||||||
|
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
|
||||||
|
return f"{n}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(s: str) -> str:
|
||||||
|
"""Normalizacja do porównań duplikatów: bez skrajnych spacji, jedna spacja, lower."""
|
||||||
|
return " ".join(str(s).strip().lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _facet_samples(rows: list[dict], tokens: list[str]) -> list[dict]:
|
||||||
|
"""Rekordy, których sygnifikator zawiera WSZYSTKIE tokeny — bez szumu i bez duplikatów.
|
||||||
|
|
||||||
|
Duplikat = ten sam sygnifikator ORAZ ten sam opis (po normalizacji). Dedup
|
||||||
|
działa na zagregowanym wyniku, więc odsiewa też powtórki między wieloma bazami.
|
||||||
|
"""
|
||||||
|
toks = [t.lower() for t in tokens if t]
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for r in rows:
|
||||||
|
sig = str(r.get("significator") or "").strip()
|
||||||
|
low = sig.lower()
|
||||||
|
if not all(t in low for t in toks):
|
||||||
|
continue
|
||||||
|
eff = _effect(r)
|
||||||
|
if _is_noise(sig, eff):
|
||||||
|
continue
|
||||||
|
key = (_norm(sig), _norm(eff))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append({"significator": sig, "expanded": expand(sig), "effect": eff})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _group_by_effect(samples: list[dict]) -> list[dict]:
|
||||||
|
"""Grupuje próbki po opisie: ten sam efekt = jedna grupa z listą sygnifikatorów."""
|
||||||
|
groups: dict[str, dict] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for s in samples:
|
||||||
|
key = _norm(s["effect"])
|
||||||
|
g = groups.get(key)
|
||||||
|
if g is None:
|
||||||
|
g = {"effect": s["effect"], "count": 0, "significators": []}
|
||||||
|
groups[key] = g
|
||||||
|
order.append(key)
|
||||||
|
g["count"] += 1
|
||||||
|
g["significators"].append(s["expanded"])
|
||||||
|
result = [groups[k] for k in order]
|
||||||
|
result.sort(key=lambda g: g["count"], reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(
|
||||||
|
positions: list[dict],
|
||||||
|
data: DataSource,
|
||||||
|
aspects: list[dict] | None = None,
|
||||||
|
per_object_limit: int = 5000,
|
||||||
|
group: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||||
|
|
||||||
|
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
|
||||||
|
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
|
||||||
|
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
|
||||||
|
"""
|
||||||
|
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
||||||
|
|
||||||
|
items: list[dict] = []
|
||||||
|
provider = None
|
||||||
|
for p in positions:
|
||||||
|
name = p.get("name")
|
||||||
|
if name not in PLANET_ABBR:
|
||||||
|
continue
|
||||||
|
planet_tok = "[" + PLANET_ABBR[name]
|
||||||
|
raw = data.search(
|
||||||
|
key="significator",
|
||||||
|
value=planet_tok,
|
||||||
|
exact=False,
|
||||||
|
limit=per_object_limit,
|
||||||
|
fields=["significator", "actioneffect", "topicresult", "bodypart"],
|
||||||
|
)
|
||||||
|
provider = raw.get("provider", provider)
|
||||||
|
rows = raw.get("rows", [])
|
||||||
|
|
||||||
|
facets: list[dict] = []
|
||||||
|
sign = p.get("sign")
|
||||||
|
sign_tok = "[" + SIGN_TO_ABBR.get(sign, "")
|
||||||
|
sign_samples = _facet_samples(rows, [sign_tok])
|
||||||
|
facets.append({
|
||||||
|
"type": "sign", "label": f"w znaku {sign}", "token": sign_tok,
|
||||||
|
"count": len(sign_samples), "samples": sign_samples,
|
||||||
|
})
|
||||||
|
|
||||||
|
house = p.get("house")
|
||||||
|
if house:
|
||||||
|
ordn = _ordinal(int(house))
|
||||||
|
house_samples = _facet_samples(rows, [f"{ordn} h"]) # matcuje '12th H.'
|
||||||
|
facets.append({
|
||||||
|
"type": "house", "label": f"w {ordn} domu", "token": f"{ordn} H.",
|
||||||
|
"count": len(house_samples), "samples": house_samples,
|
||||||
|
})
|
||||||
|
|
||||||
|
for asp in (aspects or []):
|
||||||
|
if name not in (asp.get("obj1"), asp.get("obj2")):
|
||||||
|
continue
|
||||||
|
other = asp["obj2"] if asp["obj1"] == name else asp["obj1"]
|
||||||
|
asp_tok = ASP_TOKEN.get(asp["aspect"])
|
||||||
|
if other not in PLANET_ABBR or not asp_tok:
|
||||||
|
continue
|
||||||
|
other_tok = "[" + PLANET_ABBR[other]
|
||||||
|
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
|
||||||
|
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
|
||||||
|
continue
|
||||||
|
as_suffix = f" ({asp['as']})" if asp.get("as") else ""
|
||||||
|
facets.append({
|
||||||
|
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}{as_suffix}",
|
||||||
|
"token": f"{asp_tok} + {other_tok}",
|
||||||
|
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
|
||||||
|
"applying": asp.get("applying"),
|
||||||
|
"count": len(asp_samples), "samples": asp_samples,
|
||||||
|
})
|
||||||
|
|
||||||
|
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
||||||
|
for f in facets:
|
||||||
|
f["score"] = score_facet(f)
|
||||||
|
if group:
|
||||||
|
f["groups"] = _group_by_effect(f["samples"])
|
||||||
|
facets.sort(key=lambda f: f["score"], reverse=True)
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"object": name,
|
||||||
|
"sign": sign,
|
||||||
|
"house": house,
|
||||||
|
"direction": p.get("direction"),
|
||||||
|
"planet_token": planet_tok,
|
||||||
|
"planet_total": raw.get("total", 0),
|
||||||
|
"facets": facets,
|
||||||
|
})
|
||||||
|
return {"provider": provider, "objects": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _event_tokens(event: dict) -> list[str]:
|
||||||
|
"""Tokeny bazy dla zdarzenia osi czasu (spięcie 1B→2B). Pierwszy = planeta."""
|
||||||
|
from app.engine.aspects import DB_TOKEN
|
||||||
|
|
||||||
|
if event.get("technique") == "solar_arc":
|
||||||
|
p = PLANET_ABBR.get(event.get("directed"))
|
||||||
|
a = DB_TOKEN.get(event.get("aspect"))
|
||||||
|
q = PLANET_ABBR.get(event.get("target")) # None dla Asc/MC (nie ma tokenu)
|
||||||
|
toks = []
|
||||||
|
if p:
|
||||||
|
toks.append("[" + p)
|
||||||
|
if a:
|
||||||
|
toks.append(a)
|
||||||
|
if q:
|
||||||
|
toks.append("[" + q)
|
||||||
|
return toks if p else []
|
||||||
|
if event.get("technique") == "profection":
|
||||||
|
lord = PLANET_ABBR.get(event.get("lord"))
|
||||||
|
sign = SIGN_TO_ABBR.get(event.get("sign"))
|
||||||
|
toks = []
|
||||||
|
if lord:
|
||||||
|
toks.append("[" + lord)
|
||||||
|
if sign:
|
||||||
|
toks.append("[" + sign)
|
||||||
|
return toks if lord else []
|
||||||
|
if event.get("technique") == "firdaria":
|
||||||
|
major = PLANET_ABBR.get(event.get("fd_major"))
|
||||||
|
sub = PLANET_ABBR.get(event.get("fd_sub"))
|
||||||
|
toks = []
|
||||||
|
if major:
|
||||||
|
toks.append("[" + major)
|
||||||
|
if sub:
|
||||||
|
toks.append("[" + sub)
|
||||||
|
return toks if major else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_events(events: list[dict], data: DataSource, limit: int = 4,
|
||||||
|
per_object_limit: int = 5000) -> list[dict]:
|
||||||
|
"""Dopina interpretacje z bazy do zdarzeń osi czasu (predykcyjne 1B → 2B).
|
||||||
|
|
||||||
|
Wyszukuje po tokenie planety zdarzenia i zawęża do wszystkich tokenów
|
||||||
|
(aspekt/druga planeta lub znak), z odsiewaniem szumu i duplikatów.
|
||||||
|
"""
|
||||||
|
for ev in events:
|
||||||
|
tokens = _event_tokens(ev)
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
raw = data.search(
|
||||||
|
key="significator", value=tokens[0], exact=False, limit=per_object_limit,
|
||||||
|
fields=["significator", "actioneffect", "topicresult", "bodypart"],
|
||||||
|
)
|
||||||
|
samples = _facet_samples(raw.get("rows", []), tokens)
|
||||||
|
ev["interpretations"] = samples[:limit]
|
||||||
|
ev["interpretations_count"] = len(samples)
|
||||||
|
return events
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Wspólne fixture'y testów silnika.
|
||||||
|
|
||||||
|
Horoskop referencyjny = przykład z notatek projektu (notes3): 30.04.1984,
|
||||||
|
09:35 CEST = 07:35 UTC, Warszawa. Pozycje znane z astro.com/Swiss Ephemeris
|
||||||
|
służą jako wyrocznia (LOG-25).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
REFERENCE_UTC = dt.datetime(1984, 4, 30, 7, 35, 0, tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def reference_moment() -> ChartMoment:
|
||||||
|
return ChartMoment(when_utc=REFERENCE_UTC, lat=52.2333, lon=21.0167)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def own_engine():
|
||||||
|
pytest.importorskip("skyfield")
|
||||||
|
from app.engine.skyfield_engine import SkyfieldEngine
|
||||||
|
|
||||||
|
try:
|
||||||
|
return SkyfieldEngine()
|
||||||
|
except Exception as e: # brak efemeryd / brak sieci
|
||||||
|
# Lokalnie pomijamy (dev bez pobranego jądra), ale w CI to musi być błąd —
|
||||||
|
# inaczej testy referencyjne (walidacja względem astro.com) cicho znikają.
|
||||||
|
if os.getenv("CI"):
|
||||||
|
pytest.fail(f"CI: silnik efemeryd niedostępny — testy referencyjne muszą działać: {e}")
|
||||||
|
pytest.skip(f"Nie można wczytać efemeryd: {e}")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Testy rozwijania skrótów sygnifikatorów."""
|
||||||
|
from app.abbreviations import expand
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_in_sign():
|
||||||
|
assert expand("[Su in [Tau") == "Sun in Taurus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_aspect_and_house():
|
||||||
|
assert expand("[Sa [conj [Su in 6th H.") == "Saturn conjunction Sun in 6th house"
|
||||||
|
|
||||||
|
|
||||||
|
def test_affliction_word_and_house():
|
||||||
|
assert expand("[Ne affl. in the 7th H.") == "Neptune afflicted in the 7th house"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_token_kept_without_bracket():
|
||||||
|
assert expand("[Xyz rising") == "Xyz rising"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_passthrough():
|
||||||
|
assert expand("") == ""
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
||||||
|
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
|
||||||
|
|
||||||
|
|
||||||
|
def test_separation_wraparound():
|
||||||
|
assert separation(10, 350) == 20
|
||||||
|
assert separation(0, 180) == 180
|
||||||
|
assert separation(0, 90) == 90
|
||||||
|
|
||||||
|
|
||||||
|
def test_conjunction_and_opposition():
|
||||||
|
pos = [
|
||||||
|
{"name": "Sun", "decimal": 10.0},
|
||||||
|
{"name": "Moon", "decimal": 12.0}, # 2° od Słońca -> koniunkcja
|
||||||
|
{"name": "Mars", "decimal": 190.0}, # 180° od Słońca -> opozycja
|
||||||
|
]
|
||||||
|
pairs = {(a["obj1"], a["obj2"], a["aspect"]) for a in find_aspects(pos)}
|
||||||
|
assert ("Sun", "Moon", "conjunction") in pairs
|
||||||
|
assert ("Sun", "Mars", "opposition") in pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_orb_limit_excludes_wide():
|
||||||
|
pos = [{"name": "Mercury", "decimal": 0.0}, {"name": "Venus", "decimal": 100.0}]
|
||||||
|
assert find_aspects(pos, orb=8.0, luminary_bonus=0.0) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_luminary_bonus_widens_orb():
|
||||||
|
# 99.5° -> 9.5° od kwadratury; z bonusem luminarza (8+2) mieści się
|
||||||
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Saturn", "decimal": 99.5}]
|
||||||
|
assert any(a["aspect"] == "square" for a in find_aspects(pos))
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_aspect_per_pair():
|
||||||
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||||
|
assert len(find_aspects(pos)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_applying_when_faster_body_catches_up():
|
||||||
|
# Księżyc 5° za Słońcem, szybszy -> koniunkcja aplikacyjna
|
||||||
|
pos = [
|
||||||
|
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
|
||||||
|
{"name": "Moon", "decimal": 35.0, "speed": 13.0},
|
||||||
|
]
|
||||||
|
a = find_aspects(pos)[0]
|
||||||
|
assert a["applying"] is True and a["as"] == "A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_separating_when_moving_apart():
|
||||||
|
# Księżyc 5° przed Słońcem i szybszy -> koniunkcja separacyjna
|
||||||
|
pos = [
|
||||||
|
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
|
||||||
|
{"name": "Moon", "decimal": 45.0, "speed": 13.0},
|
||||||
|
]
|
||||||
|
a = find_aspects(pos)[0]
|
||||||
|
assert a["applying"] is False and a["as"] == "S"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rigid_pair_nodes_not_reported():
|
||||||
|
# SN = NN + 180° z definicji — trywialna opozycja, nie aspekt
|
||||||
|
pos = [
|
||||||
|
{"name": "North Node", "decimal": 42.0, "speed": -0.053},
|
||||||
|
{"name": "South Node", "decimal": 222.0, "speed": -0.053},
|
||||||
|
{"name": "Sun", "decimal": 42.5, "speed": 0.96},
|
||||||
|
]
|
||||||
|
pairs = {frozenset((a["obj1"], a["obj2"])) for a in find_aspects(pos)}
|
||||||
|
assert frozenset({"North Node", "South Node"}) not in pairs
|
||||||
|
# aspekty węzłów do innych obiektów zostają nietknięte
|
||||||
|
assert frozenset({"Sun", "North Node"}) in pairs
|
||||||
|
assert frozenset({"Sun", "South Node"}) in pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_rigid_pairs_in_real_chart(own_engine, reference_moment):
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
chart = build_chart(own_engine, reference_moment)
|
||||||
|
found = [a for a in chart["aspects"] if frozenset((a["obj1"], a["obj2"])) in RIGID_PAIRS]
|
||||||
|
assert not found, f"trywialne aspekty par sztywnych w horoskopie: {found}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_as_flag_without_speeds():
|
||||||
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||||
|
assert "as" not in find_aspects(pos)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# Referencja A/S z notes3 (astro-seek) dla horoskopu 30.04.1984 07:35 UT, Warszawa.
|
||||||
|
REFERENCE_AS = {
|
||||||
|
("Sun", "Moon", "conjunction"): "A",
|
||||||
|
("Sun", "Jupiter", "trine"): "A",
|
||||||
|
("Sun", "Saturn", "opposition"): "A",
|
||||||
|
("Sun", "Neptune", "trine"): "S",
|
||||||
|
("Sun", "Pluto", "opposition"): "S",
|
||||||
|
("Moon", "Mercury", "conjunction"): "S",
|
||||||
|
("Moon", "Venus", "conjunction"): "S",
|
||||||
|
("Moon", "Neptune", "trine"): "A",
|
||||||
|
("Moon", "Pluto", "opposition"): "S",
|
||||||
|
("Mercury", "Venus", "conjunction"): "S",
|
||||||
|
("Mercury", "Neptune", "trine"): "S",
|
||||||
|
("Mercury", "Pluto", "opposition"): "S",
|
||||||
|
("Venus", "Neptune", "trine"): "A",
|
||||||
|
("Venus", "Pluto", "opposition"): "A",
|
||||||
|
("Jupiter", "Saturn", "sextile"): "A",
|
||||||
|
("Neptune", "Pluto", "sextile"): "S",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_applying_separating_matches_astroseek_reference(own_engine, reference_moment):
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
chart = build_chart(own_engine, reference_moment)
|
||||||
|
got = {(a["obj1"], a["obj2"], a["aspect"]): a.get("as") for a in chart["aspects"]}
|
||||||
|
mismatches = {k: (got.get(k), v) for k, v in REFERENCE_AS.items() if got.get(k) != v}
|
||||||
|
assert not mismatches, f"rozbieżności A/S vs astro-seek: {mismatches}"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Firdaria (LOG-11) — sekta, kolejność, długości okresów, podokresy."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.firdaria import DAY_ORDER, NIGHT_ORDER, YEARS, firdaria, is_day_birth
|
||||||
|
from app.significators import _event_tokens
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
# horoskop referencyjny: Asc 112.18, MC 352.59, Sun 40.14 -> Day birth (notes3)
|
||||||
|
ASC, MC, SUN = 112.18, 352.59, 40.14
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_is_day_birth():
|
||||||
|
assert is_day_birth(SUN, ASC, MC) is True # notes3: "Day birth (Diurnal)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_birth_uses_night_order():
|
||||||
|
# Słońce po stronie IC (przeciwna półkula) -> noc
|
||||||
|
assert is_day_birth((SUN + 180) % 360, ASC, MC) is False
|
||||||
|
fd = firdaria(BIRTH, (SUN + 180) % 360, ASC, MC)
|
||||||
|
assert fd["sect"] == "night" and fd["order"] == NIGHT_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_diurnal_sequence_and_totals():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
assert fd["sect"] == "day" and fd["order"] == DAY_ORDER
|
||||||
|
lords = [p["lord"] for p in fd["periods"]]
|
||||||
|
assert lords == DAY_ORDER + ["North Node", "South Node"]
|
||||||
|
assert sum(p["years"] for p in fd["periods"]) == 75 # 70 + 3 + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_majors_have_seven_subperiods_summing_to_period():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for p in fd["periods"]:
|
||||||
|
if p["lord"] in YEARS:
|
||||||
|
assert len(p["sub"]) == 7
|
||||||
|
assert p["sub"][0]["lord"] == p["lord"] # sub zaczyna się od władcy okresu
|
||||||
|
assert p["sub"][0]["start"] == p["start"]
|
||||||
|
assert p["sub"][-1]["end"] == p["end"]
|
||||||
|
else:
|
||||||
|
assert "sub" not in p # węzły bez podokresów
|
||||||
|
|
||||||
|
|
||||||
|
def test_periods_are_contiguous():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for a, b in zip(fd["periods"], fd["periods"][1:]):
|
||||||
|
assert a["end"] == b["start"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_42_is_saturn_major():
|
||||||
|
# kumulatywnie: Su10 Ve8 Me13 Mo9 -> 40; Saturn 40-51 -> wiek 42 w Saturnie
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
saturn = next(p for p in fd["periods"] if p["lord"] == "Saturn")
|
||||||
|
assert saturn["start"].startswith("2024") and saturn["end"].startswith("2035")
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_firdaria():
|
||||||
|
ev = {"technique": "firdaria", "fd_major": "Saturn", "fd_sub": "Jupiter"}
|
||||||
|
assert _event_tokens(ev) == ["[Sa", "[Ju"]
|
||||||
@@ -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")
|
||||||
@@ -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 (90–120°)
|
||||||
|
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
||||||
|
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Dostawcy LLM (LOG-31) — bez wołania jakiegokolwiek prawdziwego modelu.
|
||||||
|
|
||||||
|
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
|
||||||
|
deterministyczne i nic nie wychodzi na zewnątrz.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.llm import factory
|
||||||
|
from app.llm.base import Completion, LLMError
|
||||||
|
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_client(handler):
|
||||||
|
"""Podmienia httpx.Client na wersję z transportem testowym."""
|
||||||
|
class _C(httpx.Client):
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
kw["transport"] = httpx.MockTransport(handler)
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
return _C
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chat_ok(monkeypatch):
|
||||||
|
def handler(request):
|
||||||
|
assert request.url.path.endswith("/chat/completions")
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"model": "test-model",
|
||||||
|
"choices": [{"message": {"content": "Horoskop testowy."}}],
|
||||||
|
"usage": {"prompt_tokens": 100, "completion_tokens": 50},
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- protokół chat/completions
|
||||||
|
|
||||||
|
def test_local_provider_generates(chat_ok):
|
||||||
|
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||||
|
out = p.generate("prompt", 500)
|
||||||
|
assert isinstance(out, Completion)
|
||||||
|
assert out.text == "Horoskop testowy."
|
||||||
|
assert out.usage["completion_tokens"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_provider_does_not_leave_lan(chat_ok):
|
||||||
|
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||||
|
assert p.generate("prompt", 100).leaves_lan is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_provider_marks_leaving_lan(chat_ok):
|
||||||
|
p = ChatCompletionsProvider("openai", "https://api.openai.com/v1", "m", "klucz",
|
||||||
|
leaves_lan=True)
|
||||||
|
assert p.generate("prompt", 100).leaves_lan is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_key_sent_only_when_set(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen["auth"] = request.headers.get("authorization")
|
||||||
|
return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}]})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||||
|
assert seen["auth"] is None
|
||||||
|
ChatCompletionsProvider("openai", "http://x/v1", "m", "tajny").generate("p", 10)
|
||||||
|
assert seen["auth"] == "Bearer tajny"
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_error_becomes_readable_message(monkeypatch):
|
||||||
|
monkeypatch.setattr(httpx, "Client",
|
||||||
|
_mock_client(lambda r: httpx.Response(400, text="zly model")))
|
||||||
|
with pytest.raises(LLMError, match="400"):
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_response_reported(monkeypatch):
|
||||||
|
monkeypatch.setattr(httpx, "Client",
|
||||||
|
_mock_client(lambda r: httpx.Response(200, json={"nonsens": 1})))
|
||||||
|
with pytest.raises(LLMError, match="kształt"):
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retries_then_succeeds(monkeypatch):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] < 3:
|
||||||
|
return httpx.Response(429, text="za duzo")
|
||||||
|
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
monkeypatch.setattr("app.llm.providers.time.sleep", lambda s: None) # bez czekania
|
||||||
|
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10).text == "ok"
|
||||||
|
assert calls["n"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Anthropic
|
||||||
|
|
||||||
|
def test_anthropic_generates(monkeypatch):
|
||||||
|
def handler(request):
|
||||||
|
assert request.url.path.endswith("/v1/messages")
|
||||||
|
assert request.headers.get("x-api-key") == "klucz"
|
||||||
|
assert request.headers.get("anthropic-version")
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"model": "claude-x",
|
||||||
|
"content": [{"type": "text", "text": "Prognoza."}],
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
out = AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
|
||||||
|
assert out.text == "Prognoza." and out.leaves_lan is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_requires_key():
|
||||||
|
with pytest.raises(LLMError, match="LLM_API_KEY"):
|
||||||
|
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- fabryka
|
||||||
|
|
||||||
|
def test_default_provider_is_local(monkeypatch):
|
||||||
|
monkeypatch.delenv("LLM_PROVIDER", raising=False)
|
||||||
|
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||||
|
monkeypatch.delenv("LLM_BASE_URL", raising=False)
|
||||||
|
p = factory.build_provider()
|
||||||
|
assert p.name == "local"
|
||||||
|
# domyślnie NIC nie opuszcza sieci — prompt niesie opisy z baz (LOG-32)
|
||||||
|
assert p.leaves_lan is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_requires_key(monkeypatch):
|
||||||
|
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
|
||||||
|
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
|
||||||
|
factory.build_provider("openai")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_provider_rejected():
|
||||||
|
with pytest.raises(LLMError, match="Nieznany dostawca"):
|
||||||
|
factory.build_provider("bzdura")
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_overrides_model_and_url(monkeypatch):
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "moj-model")
|
||||||
|
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
|
||||||
|
p = factory.build_provider("local")
|
||||||
|
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------- konfiguracja per dostawca (regresja LOG-31)
|
||||||
|
# UI pozwala przelaczac dostawce przy kazdym zadaniu, wiec ustawienia JEDNEGO nie moga
|
||||||
|
# przeciekac na pozostalych. Wczesniej wspolne LLM_BASE_URL/LLM_MODEL kierowaly zadania
|
||||||
|
# do OpenAI na adres lokalnej Ollamy i prosily Anthropic o model llama.
|
||||||
|
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
for v in ("LLM_PROVIDER", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_KEY",
|
||||||
|
"LOCAL_MODEL", "LOCAL_BASE_URL", "LOCAL_API_KEY",
|
||||||
|
"OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY",
|
||||||
|
"ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
|
||||||
|
monkeypatch.delenv(v, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_config_does_not_leak_to_cloud(monkeypatch):
|
||||||
|
"""Sedno bledu: skonfigurowany model lokalny przejmowal zadania do chmury."""
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||||
|
monkeypatch.setenv("LLM_BASE_URL", "http://ollama:11434/v1") # konfiguracja lokalnego
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "llama3.1:8b")
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||||
|
|
||||||
|
local = factory.build_provider("local")
|
||||||
|
assert local.base_url == "http://ollama:11434/v1" and local.model == "llama3.1:8b"
|
||||||
|
|
||||||
|
openai = factory.build_provider("openai")
|
||||||
|
assert openai.base_url == "https://api.openai.com/v1", "zadanie do OpenAI poszloby do Ollamy"
|
||||||
|
assert openai.model == "gpt-4o-mini", "OpenAI dostalby nazwe modelu llama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_specific_settings_win(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
|
||||||
|
p = factory.build_provider("anthropic")
|
||||||
|
assert p.model == "claude-opus-4-8" and p.api_key == "sk-ant"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_vars_apply_only_to_default_provider(monkeypatch):
|
||||||
|
"""Zgodnosc wstecz: wspolne LLM_* konfiguruja dostawce domyslnego i tylko jego."""
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "openai")
|
||||||
|
monkeypatch.setenv("LLM_API_KEY", "sk-generic")
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "gpt-4o")
|
||||||
|
assert factory.build_provider("openai").model == "gpt-4o"
|
||||||
|
assert factory.build_provider("local").model == "llama3.1:8b" # nie dziedziczy
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_without_key_is_rejected_clearly(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||||
|
for name in ("openai", "anthropic"):
|
||||||
|
with pytest.raises(LLMError, match=f"{name.upper()}_API_KEY"):
|
||||||
|
factory.build_provider(name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_needs_no_key(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
assert factory.build_provider("local").api_key == ""
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Lots / punkty arabskie (LOG-08).
|
||||||
|
|
||||||
|
Wyrocznie z notes3 (astro-seek, horoskop referencyjny 30.04.1984, urodzenie DZIENNE):
|
||||||
|
- Fortuna wprost w tabeli obiektów: Cancer 12°35'24" = 102.5900°
|
||||||
|
- Ducha (Spirit) potwierdza jego antyscja: Taurus 28°14' -> Spirit = 180 − 58.2333
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.formats import sign_index
|
||||||
|
from app.engine.lots import LOT_DEFS, compute_lots
|
||||||
|
|
||||||
|
# natalne długości horoskopu referencyjnego
|
||||||
|
NATAL = {"Asc": 112.1786, "Sun": 40.1392, "Moon": 30.5514, "Mercury": 27.3839,
|
||||||
|
"Venus": 27.6769, "Mars": 234.5411, "Jupiter": 282.9617, "Saturn": 223.3108}
|
||||||
|
|
||||||
|
FORTUNE_REF = 102.5900 # Cancer 12°35'24"
|
||||||
|
SPIRIT_REF = 180.0 - 58.2333 # z antyscji Taurus 28°14'
|
||||||
|
|
||||||
|
|
||||||
|
def _by_name(lots):
|
||||||
|
return {lot["name"]: lot["longitude"] for lot in lots}
|
||||||
|
|
||||||
|
|
||||||
|
def _arcmin(a, b):
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_matches_astroseek():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
assert _arcmin(lots["Fortune"], FORTUNE_REF) < 2.0
|
||||||
|
assert sign_index(lots["Fortune"]) == 3 # Cancer
|
||||||
|
|
||||||
|
|
||||||
|
def test_spirit_matches_antiscia_reference():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
assert _arcmin(lots["Spirit"], SPIRIT_REF) < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_and_spirit_reverse_at_night():
|
||||||
|
day = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
night = _by_name(compute_lots(NATAL, is_day=False))
|
||||||
|
# nocą formuła się odwraca: Fortuna nocna = Duch dzienny (i odwrotnie)
|
||||||
|
assert _arcmin(night["Fortune"], day["Spirit"]) < 0.01
|
||||||
|
assert _arcmin(night["Spirit"], day["Fortune"]) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_seven_hermetic_lots_present():
|
||||||
|
lots = compute_lots(NATAL, is_day=True)
|
||||||
|
assert [lot["name"] for lot in lots] == [d[0] for d in LOT_DEFS]
|
||||||
|
assert len(lots) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_derived_lots_use_fortune_and_spirit():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
# Necessity = Asc + Fortune − Mercury
|
||||||
|
expected = (NATAL["Asc"] + lots["Fortune"] - NATAL["Mercury"]) % 360.0
|
||||||
|
assert _arcmin(lots["Necessity"], expected) < 0.01
|
||||||
|
# Eros = Asc + Venus − Spirit
|
||||||
|
expected_eros = (NATAL["Asc"] + NATAL["Venus"] - lots["Spirit"]) % 360.0
|
||||||
|
assert _arcmin(lots["Eros"], expected_eros) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_sign_method_lands_on_sign_start():
|
||||||
|
lots = compute_lots(NATAL, is_day=True, method="sign")
|
||||||
|
for lot in lots:
|
||||||
|
assert lot["longitude"] % 30.0 == 0.0 # 0° wyliczonego znaku
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_method_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compute_lots(NATAL, is_day=True, method="bzdura")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Testy punktów wirtualnych (LOG-02): mean Node, mean Lilith.
|
||||||
|
|
||||||
|
Referencje dla 30.04.1984 07:35 UT:
|
||||||
|
- astro-seek (notes3): North Node (M) = Gem 8°09'24" = 68.1567°
|
||||||
|
- wyrocznia swisseph (MEAN_NODE / MEAN_APOG, tryb Moshiera):
|
||||||
|
NN = 68.1569°, Lilith = 345.6840°
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_arcmin(a: float, b: float) -> float:
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def by_name(own_engine, reference_moment):
|
||||||
|
return {p.name: p for p in own_engine.positions(reference_moment)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mean_node_matches_references(by_name):
|
||||||
|
nn = by_name["North Node"]
|
||||||
|
assert _delta_arcmin(nn.longitude, 68.1567) < 2.0 # astro-seek
|
||||||
|
assert _delta_arcmin(nn.longitude, 68.1569) < 2.0 # swisseph
|
||||||
|
assert nn.sign == "Gemini"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nodes_always_retrograde_and_opposed(by_name):
|
||||||
|
nn, sn = by_name["North Node"], by_name["South Node"]
|
||||||
|
assert nn.retrograde and sn.retrograde # mean node zawsze Rx
|
||||||
|
assert _delta_arcmin(sn.longitude, norm360(nn.longitude + 180.0)) < 0.01
|
||||||
|
assert abs(nn.speed - sn.speed) < 1e-9 # ta sama prędkość
|
||||||
|
|
||||||
|
|
||||||
|
def test_mean_lilith_matches_swisseph(by_name):
|
||||||
|
li = by_name["Lilith"]
|
||||||
|
assert _delta_arcmin(li.longitude, 345.6840) < 3.0 # wyrocznia swisseph
|
||||||
|
assert li.sign == "Pisces"
|
||||||
|
assert li.speed > 0 and not li.retrograde # mean Lilith zawsze direct
|
||||||
|
|
||||||
|
|
||||||
|
def test_points_join_houses_and_chart(own_engine, reference_moment):
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
chart = build_chart(own_engine, reference_moment)
|
||||||
|
by = {p["name"]: p for p in chart["positions"]}
|
||||||
|
# NN w Gem -> 12. dom Whole Sign (Asc w Raku); zgodnie z tabelą astro-seek w notes3
|
||||||
|
assert by["North Node"]["house"] == 12
|
||||||
|
assert by["Lilith"]["house"] == 9 # Pis -> 9. dom
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Profekcje (LOG-10) — walidacja względem tabeli astro-seek z notes3."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.profections import DOMICILE_RULERS, age_at, profected_sign, profection_rows
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
NATAL = {"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55}
|
||||||
|
|
||||||
|
# wiek -> (profektowany Asc, Władca Roku) — tabela referencyjna notes3
|
||||||
|
REFERENCE = {
|
||||||
|
0: ("Cancer", "Moon"), 1: ("Leo", "Sun"), 2: ("Virgo", "Mercury"),
|
||||||
|
3: ("Libra", "Venus"), 4: ("Scorpio", "Mars"), 5: ("Sagittarius", "Jupiter"),
|
||||||
|
6: ("Capricorn", "Saturn"), 7: ("Aquarius", "Saturn"), 8: ("Pisces", "Jupiter"),
|
||||||
|
9: ("Aries", "Mars"), 10: ("Taurus", "Venus"), 11: ("Gemini", "Mercury"),
|
||||||
|
12: ("Cancer", "Moon"), 40: ("Scorpio", "Mars"), 41: ("Sagittarius", "Jupiter"),
|
||||||
|
42: ("Capricorn", "Saturn"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_profections_match_astroseek_table():
|
||||||
|
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 43)}
|
||||||
|
for age, (asc, lord) in REFERENCE.items():
|
||||||
|
assert rows[age]["profected_asc"] == asc, f"wiek {age}"
|
||||||
|
assert rows[age]["lord_of_year"] == lord, f"wiek {age}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_profected_secondary_points_match_reference():
|
||||||
|
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 3)}
|
||||||
|
# notes3: wiek 0 -> MC Pis, Sun Tau, Moon Tau; wiek 1 -> MC Ari, Sun Gem
|
||||||
|
assert rows[0]["MC"] == "Pisces" and rows[0]["Sun"] == "Taurus"
|
||||||
|
assert rows[1]["MC"] == "Aries" and rows[1]["Sun"] == "Gemini"
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dates_are_birthdays():
|
||||||
|
rows = profection_rows(NATAL, BIRTH, 40, 3)
|
||||||
|
assert [r["from"] for r in rows] == ["2024-04-30", "2025-04-30", "2026-04-30"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_at_boundaries():
|
||||||
|
assert age_at(BIRTH, dt.datetime(2026, 4, 29, tzinfo=dt.timezone.utc)) == 41
|
||||||
|
assert age_at(BIRTH, dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc)) == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_rulers_cover_all_signs():
|
||||||
|
assert len(DOMICILE_RULERS) == 12
|
||||||
|
assert profected_sign(112.18, 12) == "Cancer" # pełny cykl wraca
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Generator promptów (LOG-29) i budżetowanie (LOG-30).
|
||||||
|
|
||||||
|
Testy nie wołają żadnego modelu — sprawdzają skład promptu i niezmienniki redukcji.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.prompt import (
|
||||||
|
BUDGETS,
|
||||||
|
build_natal_prompt,
|
||||||
|
build_period_prompt,
|
||||||
|
natal_indications,
|
||||||
|
period_indications,
|
||||||
|
reduce_indications,
|
||||||
|
)
|
||||||
|
|
||||||
|
CHART = {
|
||||||
|
"zodiac": "tropical",
|
||||||
|
"house_system": "whole_sign",
|
||||||
|
"sect": "day",
|
||||||
|
"positions": [
|
||||||
|
{"name": "Sun", "sign": "Taurus", "in_sign": "Tau 10°12'37\"", "house": 11, "direction": "D"},
|
||||||
|
{"name": "Moon", "sign": "Aries", "in_sign": "Ari 7°48'40\"", "house": 10, "direction": "D"},
|
||||||
|
],
|
||||||
|
"angles": {"Asc": {"name": "Asc", "in_sign": "Can 16°00'54\""},
|
||||||
|
"MC": {"name": "MC", "in_sign": "Pis 25°50'50\""}},
|
||||||
|
"lots": [{"name": "Fortune", "in_sign": "Can 7°15'14\"", "house": 1,
|
||||||
|
"formula": "Asc + Moon − Sun"}],
|
||||||
|
"aspects": [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.34, "as": "A"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _report(n_samples=3, score=2.0):
|
||||||
|
return {"objects": [{
|
||||||
|
"object": "Sun", "sign": "Taurus", "house": 11,
|
||||||
|
"facets": [{
|
||||||
|
"type": "sign", "label": "w znaku Taurus", "score": score,
|
||||||
|
"samples": [{"significator": f"[Su in [Tau {i}", "expanded": f"Sun in Taurus {i}",
|
||||||
|
"effect": f"efekt numer {i}"} for i in range(n_samples)],
|
||||||
|
}],
|
||||||
|
}]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- budżetowanie
|
||||||
|
|
||||||
|
def test_budget_limits_prompt_size():
|
||||||
|
# opisy MUSZĄ być różne — identyczne zlałoby grupowanie w jedną pozycję
|
||||||
|
big = {"objects": [{
|
||||||
|
"object": "Sun", "sign": "Taurus", "house": 11,
|
||||||
|
"facets": [{"type": "sign", "label": "w znaku Taurus", "score": 1.0,
|
||||||
|
"samples": [{"significator": f"[Su {i}", "expanded": f"Sun {i}",
|
||||||
|
"effect": f"opis {i} " + "x" * 200} for i in range(500)]}],
|
||||||
|
}]}
|
||||||
|
out = build_natal_prompt(CHART, big, budget="concise")
|
||||||
|
assert out["stats"]["chars"] <= BUDGETS["concise"] * 1.1 # z marginesem na sekcje stałe
|
||||||
|
assert out["stats"]["omitted"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bigger_budget_includes_more():
|
||||||
|
big = _report(n_samples=300, score=1.0)
|
||||||
|
small = build_natal_prompt(CHART, big, budget="concise")["stats"]
|
||||||
|
large = build_natal_prompt(CHART, big, budget="extensive")["stats"]
|
||||||
|
assert large["included"] > small["included"]
|
||||||
|
assert large["omitted"] < small["omitted"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouping_merges_identical_effects():
|
||||||
|
items = [
|
||||||
|
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
|
||||||
|
"significator": "Sun in Taurus", "effect": "ten sam opis"},
|
||||||
|
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
|
||||||
|
"significator": "Sun in Taurus (inny zapis)", "effect": "Ten Sam Opis "},
|
||||||
|
]
|
||||||
|
chosen, stats = reduce_indications(items, 10_000)
|
||||||
|
assert len(chosen) == 1 # zlane w jedno
|
||||||
|
assert chosen[0]["count"] == 2 # z licznikiem wystąpień
|
||||||
|
assert stats["after_grouping"] == 1
|
||||||
|
assert stats["deduplicated"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorted_by_score_desc():
|
||||||
|
items = [
|
||||||
|
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
|
||||||
|
"significator": f"s{i}", "effect": f"e{i}"} for i in range(5)
|
||||||
|
]
|
||||||
|
chosen, _ = reduce_indications(items, 10_000)
|
||||||
|
scores = [c["score"] for c in chosen]
|
||||||
|
assert scores == sorted(scores, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weakest_are_dropped_first():
|
||||||
|
items = [
|
||||||
|
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
|
||||||
|
"significator": f"s{i}", "effect": "opis " * 20} for i in range(20)
|
||||||
|
]
|
||||||
|
chosen, stats = reduce_indications(items, 400)
|
||||||
|
assert stats["omitted"] > 0
|
||||||
|
# to, co weszło, ma wagę nie niższą niż to, co odpadło
|
||||||
|
assert stats["min_score_included"] >= stats["max_score_omitted"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_effects_are_shortened():
|
||||||
|
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
|
||||||
|
"significator": "s", "effect": "x" * 1000}]
|
||||||
|
chosen, stats = reduce_indications(items, 10_000)
|
||||||
|
assert stats["shortened"] == 1
|
||||||
|
assert "skrócono" in chosen[0]["effect"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_truncates_mid_indication():
|
||||||
|
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
|
||||||
|
"significator": "s", "effect": "opis " * 50} for _ in range(10)]
|
||||||
|
chosen, _ = reduce_indications(items, 200)
|
||||||
|
assert chosen # zawsze co najmniej jedno całe
|
||||||
|
for c in chosen:
|
||||||
|
assert c["effect"].endswith(("opis", "[…skrócono]")) # nie urwane w pół słowa
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_budget_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
build_natal_prompt(CHART, _report(), budget="gigantyczny")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- skład promptu
|
||||||
|
|
||||||
|
def test_natal_prompt_contains_required_sections():
|
||||||
|
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
|
||||||
|
for section in ["# ZADANIE", "# DANE HOROSKOPU", "# WSKAZANIA Z BAZ",
|
||||||
|
"# JAK MA WYGLĄDAĆ ODPOWIEDŹ", "# ZASTRZEŻENIE"]:
|
||||||
|
assert section in p
|
||||||
|
assert "HOROSKOPU\nURODZENIOWEGO" in p or "URODZENIOWEGO" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_natal_prompt_carries_chart_data():
|
||||||
|
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
|
||||||
|
assert "Tau 10°12'37\"" in p and "dom 11" in p # pozycja + dom
|
||||||
|
assert "Can 16°00'54\"" in p # Asc
|
||||||
|
assert "Fortune" in p # Lots
|
||||||
|
assert "Sun conjunction Moon" in p or "conjunction" in p
|
||||||
|
assert "sekta: dzienna" in p
|
||||||
|
assert "1984-04-30 09:20 UTC" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_demands_citing_significators():
|
||||||
|
p = build_natal_prompt(CHART, _report())["prompt"]
|
||||||
|
assert "Teza bez wskazania jest niedopuszczalna" in p
|
||||||
|
assert "Nie zmyślaj" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_has_disclaimer():
|
||||||
|
p = build_natal_prompt(CHART, _report())["prompt"]
|
||||||
|
assert "nie stanowi porady" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_is_deterministic():
|
||||||
|
a = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
|
||||||
|
b = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_omission_is_reported_in_prompt():
|
||||||
|
out = build_natal_prompt(CHART, _report(n_samples=400, score=1.0), budget="concise")
|
||||||
|
assert out["stats"]["omitted"] > 0
|
||||||
|
assert "pominięto" in out["prompt"] # użytkownik/model wie, że coś odpadło
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_report_still_builds_prompt():
|
||||||
|
out = build_natal_prompt(CHART, {"objects": []})
|
||||||
|
assert out["stats"]["included"] == 0
|
||||||
|
assert "brak trafień" in out["prompt"]
|
||||||
|
assert "# DANE HOROSKOPU" in out["prompt"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- okresowy
|
||||||
|
|
||||||
|
EVENTS = [
|
||||||
|
{"technique": "profection", "significator": "Lord of Year: Mars", "start": "2026-04-30",
|
||||||
|
"exact": "2026-04-30", "end": "2027-04-30", "interpretations_count": 2,
|
||||||
|
"interpretations": [{"significator": "[Ma", "expanded": "Mars", "effect": "opis marsowy"}]},
|
||||||
|
{"technique": "solar_arc", "significator": "Sun conj Saturn", "start": "2026-01-01",
|
||||||
|
"exact": "2026-06-15", "end": "2026-12-31", "interpretations_count": 1,
|
||||||
|
"interpretations": [{"significator": "[Su [conj [Sa", "expanded": "Sun conjunction Saturn",
|
||||||
|
"effect": "opis saturniczny"}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_prompt_has_dates_and_timeline():
|
||||||
|
out = build_period_prompt(CHART, EVENTS, "2026-01-01", "2027-01-01")
|
||||||
|
p = out["prompt"]
|
||||||
|
assert "2026-01-01 — 2027-01-01" in p
|
||||||
|
assert "# OŚ CZASU" in p
|
||||||
|
assert "profection" in p and "solar_arc" in p
|
||||||
|
assert "2026-06-15" in p # data dokładna zdarzenia
|
||||||
|
assert "CHRONOLOGICZNIE" in p
|
||||||
|
assert out["stats"]["events"] == 2
|
||||||
|
assert out["profile"] == "period"
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_prompt_keeps_timeline_without_interpretations():
|
||||||
|
"""Padnięta warstwa danych zabiera wskazania, ale NIE oś czasu — ta jest czysto
|
||||||
|
obliczeniowa i bez niej prognoza okresowa jest bezużyteczna."""
|
||||||
|
bare = [{k: v for k, v in ev.items()
|
||||||
|
if k not in ("interpretations", "interpretations_count")} for ev in EVENTS]
|
||||||
|
out = build_period_prompt(CHART, bare, "2026-01-01", "2027-01-01")
|
||||||
|
assert out["stats"]["events"] == 2
|
||||||
|
assert "profection" in out["prompt"] and "2026-06-15" in out["prompt"]
|
||||||
|
assert out["stats"]["included"] == 0 # brak wskazań, ale oś czasu jest
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_indications_weight_by_hit_count():
|
||||||
|
items = period_indications(EVENTS)
|
||||||
|
assert [i["score"] for i in items] == [2.0, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_natal_indications_flatten_facets():
|
||||||
|
items = natal_indications(_report(n_samples=4))
|
||||||
|
assert len(items) == 4
|
||||||
|
assert all(i["context"].startswith("Sun —") for i in items)
|
||||||
|
assert all(i["score"] == 2.0 for i in items)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Solar / Lunar Return (LOG-12) — samospójność i sensowność dat."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.returns import find_return
|
||||||
|
|
||||||
|
|
||||||
|
def _lon(engine, body, when):
|
||||||
|
return engine.positions(ChartMoment(when_utc=when), [body])[0].longitude
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_arcmin(a, b):
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_hits_natal_sun(own_engine, reference_moment):
|
||||||
|
natal_sun = _lon(own_engine, "Sun", reference_moment.when_utc)
|
||||||
|
# solariusz na 42. urodziny (2026)
|
||||||
|
hit = find_return(own_engine, "solar", reference_moment,
|
||||||
|
dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc))
|
||||||
|
assert hit is not None
|
||||||
|
assert _delta_arcmin(_lon(own_engine, "Sun", hit), natal_sun) < 0.5
|
||||||
|
assert hit.month == 4 and hit.year == 2026 # w okolicy urodzin
|
||||||
|
|
||||||
|
|
||||||
|
def test_lunar_return_hits_natal_moon(own_engine, reference_moment):
|
||||||
|
natal_moon = _lon(own_engine, "Moon", reference_moment.when_utc)
|
||||||
|
hit = find_return(own_engine, "lunar", reference_moment,
|
||||||
|
dt.datetime(1984, 5, 27, tzinfo=dt.timezone.utc))
|
||||||
|
assert hit is not None
|
||||||
|
assert _delta_arcmin(_lon(own_engine, "Moon", hit), natal_moon) < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_near_birth_is_close_to_birth(own_engine, reference_moment):
|
||||||
|
# powrót szukany wokół samych urodzin = ~moment urodzenia
|
||||||
|
hit = find_return(own_engine, "solar", reference_moment, reference_moment.when_utc)
|
||||||
|
assert hit is not None
|
||||||
|
assert abs(hit - reference_moment.when_utc) < dt.timedelta(days=1)
|
||||||
@@ -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,65 @@
|
|||||||
|
"""Token międzywarstwowy (LOG-32).
|
||||||
|
|
||||||
|
Warstwa logiczna oddaje treść baz, więc musi odrzucać żądania z pominięciem UI.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
|
||||||
|
TOKEN = "tajny-token-testowy"
|
||||||
|
|
||||||
|
|
||||||
|
def _app() -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
security.install(app, "testowa")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.get("/secret")
|
||||||
|
def secret():
|
||||||
|
return {"rows": ["treść z bazy"]}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def guarded(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_TOKEN", TOKEN)
|
||||||
|
return TestClient(_app())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def open_app(monkeypatch):
|
||||||
|
monkeypatch.delenv("INTERNAL_TOKEN", raising=False)
|
||||||
|
return TestClient(_app())
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_request_without_token(guarded):
|
||||||
|
assert guarded.get("/secret").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_wrong_token(guarded):
|
||||||
|
r = guarded.get("/secret", headers={security.HEADER: "zly"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert "treść z bazy" not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepts_correct_token(guarded):
|
||||||
|
r = guarded.get("/secret", headers={security.HEADER: TOKEN})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["rows"] == ["treść z bazy"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_stays_public(guarded):
|
||||||
|
"""Sonda k8s nie zna tokenu — /health musi działać bez niego."""
|
||||||
|
assert guarded.get("/health").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_when_token_unset(open_app):
|
||||||
|
"""Brak konfiguracji = zgodność wstecz (dev), nie blokada."""
|
||||||
|
assert not security.enabled()
|
||||||
|
assert open_app.get("/secret").status_code == 200
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user