Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3655b39acf | |||
| 7bda39ab46 |
@@ -1,17 +0,0 @@
|
||||
# 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
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# Cache warstwy bazodanowej (regenerowalny)
|
||||
services/data/.cache/
|
||||
*.parquet
|
||||
*.db
|
||||
|
||||
# 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/
|
||||
@@ -1,38 +0,0 @@
|
||||
.PHONY: help up down sample reindex migrate sql dev-data dev-logic dev-presentation
|
||||
|
||||
help:
|
||||
@echo "up - uruchom wszystkie 3 warstwy (docker compose)"
|
||||
@echo "down - zatrzymaj"
|
||||
@echo "sample - wygeneruj przykładowe pliki .xlsx"
|
||||
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
|
||||
@echo "migrate - ETL: Excel -> SQL"
|
||||
@echo "sql - uruchom z warstwą SQL (DATA_PROVIDER=sql)"
|
||||
@echo "dev-* - uruchom pojedynczą warstwę lokalnie (bez dockera)"
|
||||
|
||||
up:
|
||||
docker compose up --build
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
sample:
|
||||
cd services/data && python scripts/make_sample_data.py
|
||||
|
||||
reindex:
|
||||
cd services/data && python -m app.ingest.build_index
|
||||
|
||||
migrate:
|
||||
cd services/data && python -m app.ingest.to_sql
|
||||
|
||||
sql:
|
||||
DATA_PROVIDER=sql docker compose up --build
|
||||
|
||||
# --- 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
|
||||
@@ -1,68 +1 @@
|
||||
# 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, 3 terminale)
|
||||
```bash
|
||||
cd services/data && pip install -r requirements.txt && python scripts/make_sample_data.py
|
||||
make dev-data # terminal 1 -> :8002
|
||||
make dev-logic # terminal 2 -> :8001
|
||||
make dev-presentation # terminal 3 -> :8000
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
depends_on:
|
||||
- data
|
||||
ports:
|
||||
- "8001:8001"
|
||||
|
||||
presentation:
|
||||
build: ./services/presentation
|
||||
environment:
|
||||
LOGIC_URL: http://logic:8001
|
||||
depends_on:
|
||||
- logic
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
volumes:
|
||||
data_cache:
|
||||
Binary file not shown.
@@ -0,0 +1,110 @@
|
||||
# Przegląd istniejących rozwiązań + analiza licencji
|
||||
|
||||
Dokument roboczy. Przegląd bibliotek i programów, które mogą być przydatne dla projektu **astrololo**, ze szczególnym naciskiem na **licencje i ryzyko praw autorskich** (zgodnie z prośbą). Stan: czerwiec 2026.
|
||||
|
||||
> ⚠️ **Najważniejszy wniosek (TL;DR).** Branżowy standard obliczeń — **Swiss Ephemeris** — oraz **wszystkie** popularne biblioteki astrologiczne, które na nim bazują (pyswisseph, kerykeion, immanuel, flatlib, swisseph-wasm), są objęte licencją **AGPL‑3.0 albo płatną licencją komercyjną**. AGPL wymusza udostępnienie **całego kodu źródłowego aplikacji** — i to także wtedy, gdy aplikacja jest tylko udostępniana przez sieć (SaaS / API / hosting online, o którym mowa w notatkach). Dla produktu zamkniętego z unikalnymi bazami kolaboratora oznacza to albo (a) zakup licencji komercyjnej Swiss Ephemeris, albo (b) zbudowanie warstwy obliczeń na permisywnym fundamencie (Skyfield MIT + dane NASA JPL = public domain) i samodzielną implementację części astrologicznej. Szczegóły i rekomendacja: sekcja 6.
|
||||
|
||||
Legenda ryzyka: 🟢 permisywne (MIT/BSD/PD) · 🟡 wymaga uwagi/atrybucji · 🔴 copyleft/AGPL — ryzykowne dla produktu zamkniętego.
|
||||
|
||||
---
|
||||
|
||||
## 1. Silniki efemeryd / obliczenia astronomiczne (fundament warstwy logicznej)
|
||||
|
||||
| Rozwiązanie | Co robi | Licencja | Komentarz / ryzyko |
|
||||
|---|---|---|---|
|
||||
| **Swiss Ephemeris** (C, Astrodienst — `aloistr/swisseph`) | De‑facto standard: pozycje obiektów, domy, aspekty, gwiazdy stałe, lots — pełna astrologia, najwyższa dokładność | **Dual: AGPL‑3.0 LUB komercyjna** (CHF 750 za pierwszą licencję + CHF 400 za każdą kolejną; ważna 99 lat) | 🔴 AGPL: użycie w usłudze sieciowej = obowiązek otwarcia całego kodu. Licencja komercyjna zdejmuje copyleft z **silnika**, ale wrappery językowe (poniżej) mają **własne** licencje — trzeba je sprawdzić osobno. Decyzję trzeba podjąć **przed** dystrybucją/uruchomieniem usługi. |
|
||||
| **pyswisseph** (`astrorigin/pyswisseph`) | Wrapper Pythona do Swiss Ephemeris | **AGPL‑3.0** | 🔴 To prawdopodobnie „kalkulator z GitHuba", o którym wiesz. Sam wrapper jest AGPL niezależnie od licencji silnika. |
|
||||
| **swisseph‑wasm** (`prolaxu/swisseph-wasm`) | Swiss Ephemeris skompilowany do WebAssembly (JS) | dziedziczy po Swiss Ephemeris (**AGPL/komercyjna**) | 🔴 Ta sama pułapka AGPL, tyle że w przeglądarce. |
|
||||
| **Skyfield** (`skyfielders/python-skyfield`) | Czysto astronomiczne pozycje planet (research‑grade), oparte o dane NASA JPL | **MIT** | 🟢 Permisywne, idealne dla produktu zamkniętego. **ALE**: tylko astronomia — **nie liczy domów, aspektów, lots ani technik**. Warstwę astrologiczną trzeba dopisać samemu. |
|
||||
| **Dane NASA JPL DE440 / DE441** | Współrzędne efemeryd (Chebyshev), źródło dla Skyfielda | Dane rządu USA — **faktycznie public domain**, swobodnie dystrybuowane | 🟢 Permisywny fundament danych. DE440: lata 1550–2650; DE441: −13200 do +17191. |
|
||||
| **Moshier ephemeris** (Steve Moshier, `moshier.net`) | Semi‑analityczna teoria, ~0,1″ dokładności, bez plików danych | **Public domain** | 🟢 Wolny od jakichkolwiek zobowiązań. Mniej dokładny niż JPL, ale dla astrologii w zupełności wystarcza. Istnieje też reimplementacja JS (`0xStarcat/Moshier-Ephemeris-JS`, MIT). |
|
||||
| **Astropy** | Astronomia ogólna (układy współrzędnych, czas) | **BSD‑3** | 🟢 Permisywne; pomocnicze (czas, transformacje), nie astrologia. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Biblioteki astrologiczne wyższego poziomu (domy, aspekty, lots, techniki)
|
||||
|
||||
| Rozwiązanie | Co robi | Licencja | Komentarz / ryzyko |
|
||||
|---|---|---|---|
|
||||
| **kerykeion** (`g-battaglia/kerykeion`) | Nowoczesna, utrzymywana: pozycje, domy, aspekty, wykresy SVG, synastria/tranzyty/composite | **AGPL‑3.0** | 🔴 Wymaga otwarcia projektu. Autor oferuje hostowane **Astrologer API** (REST) — korzystanie z niego **nie** narzuca copyleft na Twój kod (ale to usługa zewnętrzna, płatna, wysyłasz dane na zewnątrz). |
|
||||
| **immanuel** (`theriftlab/immanuel-python`) | Dane czytelne dla człowieka + JSON, wzorowane na astro.com / Astro Gold; na pyswisseph | **GPL‑3.0+** | 🔴 GPL (sieciowo łagodniejsze niż AGPL, ale wciąż copyleft); ciągnie AGPL‑owy pyswisseph. |
|
||||
| **flatlib** (`flatangle/flatlib`) | Astrologia tradycyjna (domy, aspekty, godności) | **kod własny: MIT** 🟢, ale **zależy od Swiss Ephemeris** 🔴 | 🟡→🔴 Sam kod flatlib jest permisywny (MIT), lecz obliczenia robi Swiss Ephemeris — więc realnie obowiązuje Cię AGPL/komercyjna SE. Słabo utrzymywane. Dobra **referencja** projektowa. |
|
||||
| **libephemeris** (`g-battaglia/libephemeris`) | API zgodne z pyswisseph, ale liczy Skyfieldem (NASA); 25 systemów domów, weryfikowane względem pyswisseph | **AGPL‑3.0** | 🔴 Ciekawe technicznie (permisywny fundament: Skyfield+JPL), ale autor wybrał AGPL → i tak copyleft. Można potraktować jako **wzorzec**, jak dołożyć domy do Skyfielda. |
|
||||
|
||||
---
|
||||
|
||||
## 3. JavaScript / front‑end (jeśli warstwa prezentacji będzie webowa)
|
||||
|
||||
| Rozwiązanie | Co robi | Licencja | Komentarz / ryzyko |
|
||||
|---|---|---|---|
|
||||
| **CircularNatalHoroscopeJS** (`0xStarcat/...`) | Liczy natalny wykres (Asc/MC, domy, tropikalny/syderyczny) — **na Moshierze, bez Swiss Ephemeris** | **MIT** | 🟢 Rzadki przypadek: obliczenia astrologiczne bez „skażenia" AGPL. Dobry wzorzec/komponent dla JS. |
|
||||
| **AstroChart** (`Kibo/AstroChart`) | Renderowanie kosmogramu (SVG) — tylko rysowanie, bez efemeryd | open‑source (zweryfikować plik LICENSE) | 🟡 Brak obliczeń = brak ryzyka efemeryd. Sprawdzić dokładnie licencję przed użyciem. |
|
||||
| **swisseph‑wasm** | (jak w sekcji 1) | AGPL/komercyjna | 🔴 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Geolokalizacja i strefy czasowe (dla pola „miejscowość → timezone → Asc/MC")
|
||||
|
||||
Notatki opisują obliczenie strefy czasowej z większej miejscowości, potem przeliczenie dla dokładnej lokalizacji. Przydatne:
|
||||
|
||||
| Rozwiązanie | Co robi | Licencja | Komentarz / ryzyko |
|
||||
|---|---|---|---|
|
||||
| **timezonefinder** (`jannikmi/timezonefinder`) | Strefa czasowa z lat/long, offline | **kod: MIT** 🟢 / **dane: ODbL** 🟡 | 🟡 Dane (timezone‑boundary‑builder, ODbL) — przy dystrybucji bazy wymagana atrybucja i klauzula share‑alike **dla samej bazy** (nie dla Twojego kodu). |
|
||||
| **GeoNames** (baza miejscowości) | Miejscowości + współrzędne + strefy (do dropdowna lokalizacji) | **CC‑BY 4.0** | 🟡 Wolne, ale **wymaga atrybucji** „GeoNames". Idealne do tabeli miejscowości z notatek. |
|
||||
| **IANA tz database (tzdata)** | Reguły stref i czasu letniego (DST) | **Public domain** | 🟢 W Pythonie przez `zoneinfo` (stdlib) — bez zależności. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Pełne programy referencyjne (do nauki algorytmów, nie do kopiowania kodu)
|
||||
|
||||
| Program | Licencja | Komentarz / ryzyko |
|
||||
|---|---|---|
|
||||
| **Astrolog** (Walter Pullen, `astrolog.org`) | **GPL‑2.0+** (od wersji 6.00) | 🔴 do kopiowania kodu (GPL), ale **bezcenny jako referencja** algorytmów (domy, dyrekcje, techniki). Opcjonalnie używa Swiss Ephemeris. Można czytać i uczyć się metod, nie wklejać kodu do produktu zamkniętego. |
|
||||
| **Maitreya / Morinus** (open‑source) | GPL (zweryfikować) | 🟡 Jw. — referencja, nie źródło kodu do zamkniętego produktu. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Strategia licencyjna — trzy ścieżki (z rekomendacją)
|
||||
|
||||
Wybór zależy od jednej decyzji z arkusza wymagań (**Q‑01**: produkt zamknięty/hostowany czy nie) i **Q‑07** (SQL/architektura). Przy założeniu **produktu zamkniętego z prywatnymi bazami** (co wynika z notatek — hosting online, własność kolaboratora):
|
||||
|
||||
**Ścieżka A — permisywna, „zbuduj sam" 🟢 (rekomendowana long‑term)**
|
||||
Skyfield (MIT) + dane JPL DE440 (public domain) lub Moshier (PD) do pozycji; domy/aspekty/lots/dyrekcje implementujemy sami (wzorując się na Astrologu i libephemeris — czytając, nie kopiując).
|
||||
- ➕ Pełna swoboda licencyjna, produkt może być zamknięty i hostowany.
|
||||
- ➖ Najwięcej pracy w warstwie logicznej (ale dokładnie tę warstwę i tak projektujemy modułowo).
|
||||
|
||||
**Ścieżka B — Swiss Ephemeris na licencji komercyjnej 🟡 (najszybsza „pełna dokładność")**
|
||||
Kupujemy licencję komercyjną SE (CHF 750) i wołamy silnik C bezpośrednio (z cienkim własnym wrapperem, by ominąć AGPL‑owy pyswisseph).
|
||||
- ➕ Od razu komplet funkcji i najwyższa dokładność; produkt zamknięty OK.
|
||||
- ➖ Koszt + formalności licencyjne; trzeba uważać, żeby nie wciągnąć AGPL‑owych wrapperów.
|
||||
|
||||
**Ścieżka C — AGPL „na całość" 🔴 (tylko jeśli produkt może być otwarty)**
|
||||
kerykeion/immanuel/flatlib+pyswisseph — najszybszy development.
|
||||
- ➕ Gotowiec, mało kodu.
|
||||
- ➖ Wymusza **otwarcie całej aplikacji** (w tym kodu obsługującego prywatne bazy). Przy hostingu online (AGPL!) praktycznie wykluczone dla tego projektu.
|
||||
|
||||
**Ścieżka D — hostowane API (np. Astrologer API)** jako uzupełnienie: korzystanie z REST nie narzuca copyleft, ale to zależność zewnętrzna, koszt, prywatność danych urodzeniowych i może nie pokrywać egzotycznych technik (firdaria, zodiacal releasing, warianty primary directions).
|
||||
|
||||
> **Rekomendacja:** zarezerwować w architekturze warstwy logicznej **interfejs silnika efemeryd** (jak `DataProvider` w warstwie danych), tak by dało się podmienić backend: `MoshierEngine` / `SkyfieldEngine` (ścieżka A) ↔ `SwissEphCommercialEngine` (ścieżka B). Wtedy decyzję A vs B można podjąć później bez przepisywania logiki.
|
||||
|
||||
---
|
||||
|
||||
## 7. Osobna kwestia: prawa autorskie do TREŚCI baz (to NIE są licencje software!)
|
||||
|
||||
To dotyczy zawartości baz interpretacji, nie kodu — i jest tu realne ryzyko do rozważenia:
|
||||
|
||||
- **Bazy kolaboratora.** Jeśli to **oryginalna twórczość** współpracownika, to on jest dysponentem praw — potrzebne jasne ustalenie (umowa/licencja) na wykorzystanie ich w produkcie. To załatwia sprawę po stronie tych danych.
|
||||
- **Źródła z opublikowanych książek.** Przykładowy plik „The Encyclopaedia of Medical Astrology" to **wydana książka** (H. L. Cornell, 1933). Cyfrowy przedruk obszernych opisów interpretacyjnych z chronionego dzieła = **ryzyko naruszenia praw autorskich**, niezależnie od licencji oprogramowania. Status zależy od jurysdykcji i daty (część dawnych dzieł może być już w domenie publicznej — wymaga sprawdzenia per tytuł).
|
||||
- **Fakt vs. twórczość.** Krótkie formuły sygnifikatorów (np. „Ma Ari = …") to raczej **fakty/metoda** — trudniej objąć je prawem autorskim. Natomiast rozbudowana **proza interpretacyjna** (kolumna Effect) jest chroniona jako utwór.
|
||||
- **Zalecenie:** dla każdej bazy odnotować **proweniencję** (autor/źródło/status praw) — najlepiej jako kolumnę/metadane w warstwie danych (pasuje do `book-#per-txt`, `author`, `lang` z notatek). Oddzielić materiał: własny kolaboratora / public domain / cytowany za zgodą / wymagający usunięcia.
|
||||
|
||||
---
|
||||
|
||||
## Źródła
|
||||
- [Swiss Ephemeris — licencja (astro.com)](https://www.astro.com/swisseph/swephinfo_e.htm) · [LICENSE](https://www.astro.com/ftp/swisseph/LICENSE) · [repo `aloistr/swisseph`](https://github.com/aloistr/swisseph)
|
||||
- [pyswisseph (`astrorigin/pyswisseph`)](https://github.com/astrorigin/pyswisseph)
|
||||
- [kerykeion (PyPI)](https://pypi.org/project/kerykeion/) · [immanuel (PyPI)](https://pypi.org/project/immanuel/) · [flatlib LICENSE (MIT)](https://raw.githubusercontent.com/flatangle/flatlib/master/LICENSE)
|
||||
- [Skyfield (PyPI, MIT)](https://pypi.org/project/skyfield/) · [libephemeris (`g-battaglia/libephemeris`)](https://github.com/g-battaglia/libephemeris)
|
||||
- [NASA JPL DE440/DE441](https://ssd.jpl.nasa.gov/doc/de440_de441.html) · [Moshier ephemeris](http://www.moshier.net/) · [Moshier‑Ephemeris‑JS](https://github.com/0xStarcat/Moshier-Ephemeris-JS)
|
||||
- [CircularNatalHoroscopeJS](https://github.com/0xStarcat/CircularNatalHoroscopeJS) · [AstroChart (`Kibo/AstroChart`)](https://github.com/Kibo/AstroChart) · [swisseph‑wasm](https://github.com/prolaxu/swisseph-wasm)
|
||||
- [timezonefinder (PyPI, MIT)](https://pypi.org/project/timezonefinder/) · [Astrolog (Wikipedia)](https://en.wikipedia.org/wiki/Astrolog)
|
||||
@@ -0,0 +1,174 @@
|
||||
# Warstwa logiczna — analiza, rozwinięcie wymagań i przypisanie rozwiązań
|
||||
|
||||
Dokument roboczy. Rozwinięcie 24 wymagań warstwy logicznej (LOG‑01…LOG‑24 z `astrololo_wymagania.xlsx`) wraz z: przypisaniem gotowych **permisywnych** rozwiązań, oceną złożoności implementacji **od zera** (bo idziemy **ścieżką A**) oraz wskazaniem, gdzie narzędzia AGPL/komercyjne (B/C) służą jako **referencja i walidacja**. Stan: czerwiec 2026.
|
||||
|
||||
---
|
||||
|
||||
## 0. Założenia ścieżki A
|
||||
|
||||
**Fundament permisywny (co dostajemy „za darmo"):**
|
||||
- **Skyfield** (MIT) + dane **NASA JPL DE440/DE441** (public domain) → geocentryczne pozycje planet i Księżyca, RA/Dec, długość/szerokość ekliptyczna, prędkości, wschody/zachody, wyszukiwanie zdarzeń (stacje, powroty, syzygia).
|
||||
- **pyerfa / ERFA** (BSD; pochodna IAU SOFA) → precesja, nutacja, czas gwiazdowy (LST), nachylenie ekliptyki (obliquity) — prymitywy potrzebne do osi (Asc/MC) i ayanams.
|
||||
- **Katalog Hipparcos** (ESA, swobodnie dostępny; ładuje go Skyfield) → gwiazdy stałe.
|
||||
- **Lark / pyparsing** (MIT) → infrastruktura parsera składni sygnifikatorów.
|
||||
- **numpy / pandas** (BSD) → cała matematyka i tabele.
|
||||
|
||||
Wniosek: **~70–80% astronomicznego fundamentu jest pokryte permisywnie**. Sami budujemy **warstwę astrologiczną** (domy, dyrekcje, techniki, parser, dopasowanie) — a jej algorytmy/wzory są **wiedzą publiczną** (opisane w literaturze astronomicznej i astrologicznej), więc **nie podlegają prawu autorskiemu** — chronione są tylko konkretne implementacje kodu. Czytamy więc Astrolog/Morinus/dokumentację Swiss Ephemeris, by **zrozumieć metodę**, a piszemy **własny** kod permisywny.
|
||||
|
||||
**Rola ścieżek B/C (Swiss Ephemeris, Morinus, astro.com):**
|
||||
1. **Wyrocznia walidacyjna** — nasz silnik porównujemy liczbowo (do łuku sekundy) z SE/astro.com. Użycie pyswisseph **w samych testach/CI** (nie dystrybuowane, nie wystawiane użytkownikom przez sieć) **nie uruchamia obowiązków AGPL** — to legalne i bardzo przydatne.
|
||||
2. **Dane referencyjne** — tabele kontrolne (np. dla profekcji, primary directions) do testów regresyjnych.
|
||||
3. **Porównanie funkcjonalne** — które warianty technik liczy „konkurencja".
|
||||
|
||||
Skala złożoności (implementacja od zera): **Trywialna · Niska · Średnia · Wysoka · Bardzo wysoka (XL)**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Macierz zbiorcza
|
||||
|
||||
| ID | Wymaganie | Gotowe permisywne (ścieżka A) | Złożoność od zera | Rola B/C |
|
||||
|----|-----------|-------------------------------|-------------------|----------|
|
||||
| LOG‑01 | Pozycje obiektów | **Skyfield** (komplet) | Niska | walidacja pozycji |
|
||||
| LOG‑02 | Taksonomia obiektów | Skyfield (planety, Księżyc), Hipparcos (gwiazdy) | **Wysoka** (true Node/Lilith, gwiazdy) | walidacja węzłów/Lilith |
|
||||
| LOG‑03 | Kierunek i stacje | Skyfield (find_discrete) | Średnia | walidacja dat stacji |
|
||||
| LOG‑04 | Systemy zodiaku / ayanamsy | Skyfield (tropikalny, RA), pyerfa (precesja) | Średnia | walidacja ayanams |
|
||||
| LOG‑05 | Systemy domów (wiele) | pyerfa (LST, obliquity); reszta własna | **Wysoka** | walidacja cusps (kluczowa) |
|
||||
| LOG‑06 | Aspekty + orby | własne (czysta matematyka) | Niska | walidacja list aspektów |
|
||||
| LOG‑07 | Aspekty pozazodiakalne | Skyfield (deklinacja) | Niska | walidacja |
|
||||
| LOG‑08 | Lots | własne (arytmetyka + DSL formuł) | Niska–Średnia | porównanie wartości |
|
||||
| LOG‑09 | **Primary Directions** | brak permisywnego | **Bardzo wysoka (XL)** | **krytyczna** (Morinus) |
|
||||
| LOG‑10 | Profekcje | własne (arytmetyka + tabele władców) | Niska–Średnia | tabela kontrolna |
|
||||
| LOG‑11 | Zod. Releasing / Firdaria / Decennials | brak permisywnego | Średnia (table‑driven) | walidacja sekwencji |
|
||||
| LOG‑12 | Returns (Solar/Lunar) | Skyfield (root‑finding) | Niska–Średnia | walidacja czasu powrotu |
|
||||
| LOG‑13 | Ascensional Times | pyerfa/własne (oblique ascension) | Średnia | tabela kontrolna |
|
||||
| LOG‑14 | Zbiorcza tabela dat | własne (orkiestracja) | Średnia | — |
|
||||
| LOG‑15 | **Parser sygnifikatorów** | Lark/pyparsing (infrastruktura) | **Wysoka** (unikalne IP) | brak referencji |
|
||||
| LOG‑16 | Precompute „atomów" | własne | Średnia | — |
|
||||
| LOG‑17 | Interpretacja „asp"/„asp±" | własne (config + LOG‑06) | Niska–Średnia | — |
|
||||
| LOG‑18 | Warstwy 1A/1B | własne (orkiestracja ↔ warstwa danych) | Średnia | — |
|
||||
| LOG‑19 | Wypis interpretacji 2B | własne (↔ DataProvider) | Średnia | — |
|
||||
| LOG‑20 | Kolumny sygnif. + „no of hits" | własne | Niska–Średnia | — |
|
||||
| LOG‑21 | Scoring siły efektu | własne (rule/data‑driven) | Średnia | — |
|
||||
| LOG‑22 | Konwersja tekst ↔ symbol | własne (tablice z notes3) | Niska | — |
|
||||
| LOG‑23 | Pozostałe wyliczenia | Skyfield (zdarzenia) | Niska | walidacja |
|
||||
| LOG‑24 | Abstrakcja silnika efemeryd | architektura | Niska | — |
|
||||
|
||||
**Tylko 3 pozycje są naprawdę trudne:** LOG‑09 (primary directions, XL), LOG‑05 (pełny zestaw domów, Wysoka), LOG‑15 (parser — Wysoka, ale to nasze unikalne IP, więc gotowca i tak nie ma). Reszta jest Niska/Średnia, w dużej części pokryta przez Skyfield.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rozwinięcia pogrupowane
|
||||
|
||||
### Grupa A — Fundament astronomiczny (LOG‑01, 02, 03, 24)
|
||||
|
||||
**LOG‑01 · Pozycje obiektów.**
|
||||
*Rozwinięcie:* długość i szerokość ekliptyczna (of‑date), prędkość (różniczkowanie pozycji), kierunek; formaty: DMS w znaku (Tau 28°12'57''), absolutne 0–360°, dziesiętne. *Ścieżka A:* Skyfield daje to wprost (`ecliptic_latlon`, prędkość z dwóch chwil). Formatowanie/podział na znaki = trywialne. *Złożoność: Niska.* *B/C:* porównać długości z astro.com do ~0,1″.
|
||||
|
||||
**LOG‑02 · Taksonomia obiektów.**
|
||||
*Rozwinięcie:* światła+planety (Skyfield 🟢), planety nowożytne (🟢), gwiazdy stałe (precesja+ruch własny — Hipparcos w Skyfield, 🟢 średnia), obiekty wirtualne:
|
||||
- **Węzły księżycowe** — *mean* (wzór analityczny, Niska) i *true/osculating* (z wektora stanu Księżyca → elementy oskulacyjne → węzeł wstępujący; **Średnia–Wysoka**).
|
||||
- **Lilith (Black Moon = apogeum Księżyca)** — *mean* (Niska–Średnia) i *true* (oskulacyjne apogeum; **Wysoka** — tu implementacje notorycznie się różnią, walidacja względem SE obowiązkowa).
|
||||
- **Osie (Asc/MC → Dsc/IC)** — patrz LOG‑05.
|
||||
*Złożoność całości: Wysoka* (przez gwiazdy + true Node/Lilith; reszta Niska). *B/C:* SE jako wzorzec dla true Node/Lilith (różne definicje!).
|
||||
|
||||
**LOG‑03 · Kierunek i stacje.**
|
||||
*Rozwinięcie:* znak prędkości → D/Rx; stacja = przejście prędkości przez 0 (SD vs SR z przyspieszenia); „dni od stacji ścisłej" + flaga <7 dni; „stacja umowna" jako konfigurowalny próg % ruchu typowego. *Ścieżka A:* Skyfield `find_discrete`/`find_maxima` znajduje momenty stacji; średnie prędkości precompute. *Złożoność: Średnia.* *B/C:* daty stacji z SE.
|
||||
|
||||
**LOG‑24 · Abstrakcja silnika.**
|
||||
*Rozwinięcie:* interfejs `EphemerisEngine` z implementacjami `SkyfieldEngine` (główna, MIT+JPL), `MoshierEngine` (fallback w pełni public‑domain, bez plików danych) oraz — opcjonalnie po decyzji — `SwissEphCommercialEngine`. *Złożoność: Niska* (architektura, analogicznie do `DataProvider`). To wymaganie **spina ścieżki A/B** bez przepisywania logiki.
|
||||
|
||||
### Grupa B — Układy odniesienia (LOG‑04, 05)
|
||||
|
||||
**LOG‑04 · Systemy zodiaku / ayanamsy.**
|
||||
*Rozwinięcie:* tropikalny (domyślny, 🟢), syderyczny = tropikalny − ayanamsa(t) (Lahiri, Fagan‑Bradley… ~40 w SE; my zaczynamy od kilku + tabela rozszerzalna), draconic = tropikalny − długość NN (🟢 po LOG‑02), RA (Skyfield daje wprost). *Ścieżka A:* precesja z pyerfa; ayanamsy to znane stałe odniesienia + tempo precesji. *Złożoność: Średnia* (głównie tabela ayanams). *B/C:* wartości ayanams z SE.
|
||||
|
||||
**LOG‑05 · Systemy domów (wiele, równolegle).** ⚠️ najcięższa matematyka astronomiczna.
|
||||
*Rozwinięcie:*
|
||||
- Whole Sign / Equal (Asc/MC) / Porphyry → **Niska** (trywialne podziały).
|
||||
- Placidus, Koch, Regiomontanus, Campanus, Topocentric, Alcabitus, Meridian, Morinus, Vehlow → znane wzory trygonometrii sferycznej, każdy **Średnia**, w komplecie **Wysoka** (przypadki brzegowe na wysokich szerokościach — Placidus/Koch nieokreślone za kołem podbiegunowym).
|
||||
*Ścieżka A:* Asc/MC z LST (pyerfa) + obliquity + szerokość geo (standardowe wzory). `libephemeris` (AGPL) dowodzi, że pełny zestaw 25 systemów **da się** policzyć na Skyfieldzie — czytamy go jako dowód wykonalności, ale piszemy własny kod. *Złożoność: Wysoka.* *B/C:* **walidacja cusps krytyczna** — tu kryją się błędy, szczególnie na dużych szerokościach.
|
||||
|
||||
### Grupa C — Aspekty (LOG‑06, 07)
|
||||
|
||||
**LOG‑06 · Aspekty + orby.**
|
||||
*Rozwinięcie:* separacja kątowa vs kąt aspektu ± orb; aplikacja/separacja z różnicy prędkości; schematy orbów (domyślne, bonus dla luminarzy, fixed, ±%, per‑planeta/szkoła); aspekt „przez znak" vs „przez stopień"; major/minor/harmonic. *Ścieżka A:* czysta matematyka na policzonych długościach — **żadna biblioteka nie jest potrzebna**. *Złożoność: Niska–Średnia* (szerokość konfiguracji, nie trudność algorytmu). *B/C:* listy aspektów z astro.com do testów.
|
||||
|
||||
**LOG‑07 · Aspekty pozazodiakalne.**
|
||||
*Rozwinięcie:* parallel/contraparallel (po deklinacji — Skyfield 🟢), antiscia/contra‑antiscia (lustro względem osi przesileń — arytmetyka). *Złożoność: Niska.*
|
||||
|
||||
### Grupa D — Lots (LOG‑08)
|
||||
|
||||
**LOG‑08 · Lots.**
|
||||
*Rozwinięcie:* silnik formuł `Lot = C + A − B` z odwracaniem dzień/noc (sekta); By Degree (domyślnie) i By Sign; 7 Hermetic Lots; cały znak działa jak Lot. *Ścieżka A:* mały DSL/parser formuł + ewaluacja na policzonych pozycjach. *Złożoność: Niska–Średnia* (logika sekty prosta; trudność tkwi w **tożsamości/wariantach** Lots — to kuracja danych, nie obliczenia; patrz Q‑03). *B/C:* porównanie wartości stopni.
|
||||
|
||||
### Grupa E — Techniki predykcyjne (LOG‑09…14)
|
||||
|
||||
**LOG‑09 · Primary Directions.** ⚠️ **najtrudniejsze (XL).**
|
||||
*Rozwinięcie:* łuk dyrekcji (semi‑arc/Placidean, Regiomontanus, …) między sygnifikatorem a promisorem w ruchu dobowym; klucz łuk→czas (Ptolemeusz/Naibod/Cardan/Solar arc); direct/converse; mundo/zodiacal; z szerokością/bez. ~160 wariantów — robimy 5–8 (Q‑02), framework rozszerzalny. *Ścieżka A:* brak gotowca permisywnego — **w całości własna trygonometria sferyczna** + obsługa wariantów. *Złożoność: Bardzo wysoka.* *B/C:* **Morinus (GPL) słynie z dokładnych primary directions** — kluczowa wyrocznia; uwaga: nawet komercyjne programy różnią się w wariantach (do udokumentowania, który wariant odwzorowujemy).
|
||||
|
||||
**LOG‑10 · Profekcje.**
|
||||
*Rozwinięcie:* annual (Asc +1 znak/rok, mod 12), monthly/daily, continuous (klucz, domyślnie 30°/rok), Lord of Year, Lord of Orb (kolejność chaldejska, mod 84/12). *Ścieżka A:* arytmetyka dat + tabele władców domicilnych — **bez bibliotek**. *Złożoność: Niska* (annual) *– Średnia* (continuous + Lord of Orb). *B/C:* tabela kontrolna (jak ta w notes3).
|
||||
|
||||
**LOG‑11 · Zodiacal Releasing / Firdaria / Decennials.**
|
||||
*Rozwinięcie:* deterministyczne sekwencje okresów z tabel hellenistycznych/perskich (ZR: długości okresów Valensa + „loosing of the bond"; Firdaria: stałe sekwencje diurnal/nocturnal z podokresami; Decennials: kolejność planetarna). *Ścieżka A:* logika sekwencjonowania na tabelach — algorytmy publiczne, niepodlegające prawu autorskiemu. *Złożoność: Średnia.* *B/C:* walidacja sekwencji z SE/Astrolog.
|
||||
|
||||
**LOG‑12 · Returns (Solar/Lunar).**
|
||||
*Rozwinięcie:* root‑finding momentu powrotu Słońca/Księżyca do długości natalnej; 2 warianty (osobny horoskop vs tranzyt do natalu); monthly revolutions (Słońce + n×30°). *Ścieżka A:* Skyfield znajduje moment (🟢), reszta = ponowne użycie LOG‑01…06. *Złożoność: Niska–Średnia.* *B/C:* czas powrotu z astro.com.
|
||||
|
||||
**LOG‑13 · Ascensional Times.**
|
||||
*Rozwinięcie:* oblique ascension znaków wg szerokości (różnica ascensjonalna z RA) → lata życia; sumy i ułamki dla dodatkowych dat. Częściowo data‑driven (tabela DAN‑07). *Ścieżka A:* wzory powiązane z LOG‑05 (trygonometria sferyczna) + pyerfa. *Złożoność: Średnia.*
|
||||
|
||||
**LOG‑14 · Zbiorcza tabela dat z technik.**
|
||||
*Rozwinięcie:* uruchom wszystkie techniki w zakresie czasu → jedna datowana tabela (`technique | significator | start | exact | end`), sortowanie, deduplikacja, daty dokładne (wspólny root‑finding z LOG‑12). *Ścieżka A:* orkiestracja/glue. *Złożoność: Średnia.*
|
||||
|
||||
### Grupa F — Parser sygnifikatorów i dopasowanie (LOG‑15…20) — **rdzeń unikalnego IP**
|
||||
|
||||
**LOG‑15 · Parser składni „Significator".** ⚠️ **Wysoka — i tak nie ma gotowca.**
|
||||
*Rozwinięcie:* gramatyka skrótów (obiekty, znaki, żywioły, jakości, domy, aspekty+cel, retro/combust/waxing/heliacal, opcje A/B, warunki, „r‑Asc", whole sign+orb); 7 poziomów złożoności — od poziomu „obiekt" po sygnifikatory opisowe/niejasne (poziomy 5–7 mogą wymagać fallbacku człowiek/AI). *Ścieżka A:* tokenizer + gramatyka PEG (Lark, MIT) + słownik SIGNIFICATORS KEY (warstwa danych) jako mapa znaczeń tokenów. *Złożoność: Wysoka* — tu idzie najwięcej oryginalnej inżynierii; to **wartość intelektualna projektu**. *B/C:* brak — nie istnieje zewnętrzny parser tej składni.
|
||||
|
||||
**LOG‑16 · Precompute „atomów" obecnych w horoskopie.**
|
||||
*Rozwinięcie:* wygeneruj zbiór wszystkich cząstek prawdziwych dla danego horoskopu (Ma Ari, Ma fire, Ma h2, Ma opp Sa, r‑Asc Ari…); dopasowanie rekordu bazy = przynależność do zbioru. Zależy od LOG‑01…08 (policzony horoskop) + LOG‑15 (słownik). *Złożoność: Średnia* (generacja kombinatoryczna + indeks; wrażliwe wydajnościowo — łączy się z DAN‑22).
|
||||
|
||||
**LOG‑17 · Interpretacja „asp"/„asp+"/„asp‑"/„asp malefic".**
|
||||
*Rozwinięcie:* rozwinięcie wg konfigurowalnej tabeli pomocniczej (jakie aspekty, orby, active) + LOG‑06. *Złożoność: Niska–Średnia.*
|
||||
|
||||
**LOG‑18/19/20 · Dopasowanie i wypis.**
|
||||
*Rozwinięcie:* silnik dopasowania: atomy → zapytanie do **warstwy danych (nasz DataProvider!)** → złożenie outputu: 1A (natal, unikalny), 1B (predykcyjne z datami), 2B (wypis interpretacji tylko dla obecnych rekordów), 2 kolumny sygnifikatora (z bazy / as‑is‑in‑chart), „no of hits" (1/2/3/…/?/2+?). To **styk warstwy logicznej z danych** — pasuje do istniejącej architektury. *Złożoność: Średnia.*
|
||||
|
||||
### Grupa G — Scoring, symbole, pozostałe (LOG‑21, 22, 23)
|
||||
|
||||
**LOG‑21 · Scoring siły efektu.**
|
||||
*Rozwinięcie:* punktacja wg tabel relacyjnych (np. „no air" poziomy */**/***), punkty za domniemaną prawdziwość w syntezie; logika indywidualna (nie zawsze rzadkość = siła). *Ścieżka A:* silnik reguł na danych (tabele relacyjne — warstwa danych). *Złożoność: Średnia*, otwarte (autor przygotuje punktację indywidualnie).
|
||||
|
||||
**LOG‑22 · Konwersja tekst ↔ symbol.**
|
||||
*Rozwinięcie:* dwukierunkowe mapowanie z tablic Unicode (kompletne w notes3); przechowywanie tekstem, wyświetlanie symbolami; tylko „text" Unicode, nigdy emoji; ℞ dla retro. *Złożoność: Niska* (tablice lookup). Ligatury vs 2 kolumny — patrz Q‑05 (prezentacja).
|
||||
|
||||
**LOG‑23 · Pozostałe wyliczenia.**
|
||||
*Rozwinięcie:* tally żywiołów/jakości (trywialne), planetary day & hours (wschód/zachód ze Skyfielda + podział na 12 godzin dnia/nocy + władcy chaldejscy), stopnie krytyczne/wrażliwe (lookup), faza Księżyca (elongacja Su‑Mo), syzygia prenatalna (root‑find ostatniej pełni/nowiu przed urodzeniem), divisional (12th/9th parts — arytmetyka). *Ścieżka A:* Skyfield obsługuje zdarzenia astronomiczne. *Złożoność: Niska* (lokalnie Niska–Średnia).
|
||||
|
||||
---
|
||||
|
||||
## 3. Proponowana kolejność budowy (fazy)
|
||||
|
||||
1. **Fundament (MVP silnika):** LOG‑24 (interfejs) → LOG‑01 → LOG‑02 (bez gwiazd/true‑Lilith) → LOG‑04 (tropikalny+RA) → LOG‑06/07 → LOG‑05 (Whole/Equal/Porphyry+Placidus) → LOG‑23 (podstawy). *Tu Skyfield daje ogromny skok.*
|
||||
2. **Astrologia rdzenia:** LOG‑08 (Lots) → LOG‑15 (parser) → LOG‑16 → LOG‑18/19/20 (dopasowanie ↔ warstwa danych). *Tu powstaje unikalna wartość i pełny przepływ „horoskop → interpretacje".*
|
||||
3. **Predykcja prosta:** LOG‑10 (profekcje) → LOG‑12 (returns) → LOG‑14 (agregacja) → LOG‑11 (time‑lordy) → LOG‑13.
|
||||
4. **Predykcja trudna i dopieszczenie:** LOG‑09 (primary directions, XL) → LOG‑02 dokończenie (gwiazdy, true Node/Lilith) → LOG‑04 (ayanamsy) → LOG‑21 (scoring) → LOG‑05 (pełny zestaw domów).
|
||||
|
||||
Logika: najpierw to, co Skyfield daje tanio i co odblokowuje przepływ end‑to‑end; najtrudniejsze (primary directions, pełne domy) na koniec, gdy mamy już solidną wyrocznię walidacyjną.
|
||||
|
||||
---
|
||||
|
||||
## 4. Stos technologiczny (permisywny) i walidacja
|
||||
|
||||
**Produkcyjne (ścieżka A):** Skyfield (MIT), pyerfa (BSD), dane JPL DE440 (PD) lub Moshier (PD), Lark (MIT), numpy/pandas (BSD). Zero copyleft → produkt może być zamknięty i hostowany.
|
||||
|
||||
**Tylko dev/test (B/C jako wyrocznia — NIE w produkcie, NIE wystawiane przez sieć):** pyswisseph (AGPL) i Morinus (GPL) do porównań liczbowych w testach regresyjnych. To legalne, bo nie dystrybuujemy ich i nie udostępniamy użytkownikom — AGPL/GPL nie zostają uruchomione.
|
||||
|
||||
**Wymaganie pochodne (do dopisania w arkuszu):** **LOG‑25 — harness walidacyjny**: zestaw testów porównujących nasz silnik z SE/astro.com/Morinus na bazie znanych horoskopów (np. przykład z notes3: 30.04.1984, Warszawa), z progami tolerancji per wielkość. To nasza polisa ubezpieczeniowa przy budowie od zera.
|
||||
|
||||
---
|
||||
|
||||
## 5. Wniosek
|
||||
|
||||
Ścieżka A jest **w pełni wykonalna**. Skyfield + pyerfa + JPL/Moshier zdejmują z nas całą trudną astronomię (pozycje, zdarzenia, czas gwiazdowy, precesja). Realny ciężar własnej implementacji to **trzy obszary**: primary directions (XL), pełny zestaw domów (Wysoka) i parser sygnifikatorów (Wysoka — ale unikalny, gotowca i tak nie ma). Wszystkie pozostałe wymagania są Niskie/Średnie. Narzędzia AGPL/komercyjne zostają jako **wyrocznia walidacyjna i dane referencyjne** — używane legalnie poza produktem.
|
||||
@@ -1,10 +0,0 @@
|
||||
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"]
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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
@@ -1,22 +0,0 @@
|
||||
"""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}"
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
"""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:
|
||||
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
|
||||
frame.to_parquet(self._path(fp, sheet), index=False)
|
||||
Vendored
-68
@@ -1,68 +0,0 @@
|
||||
"""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
@@ -1,39 +0,0 @@
|
||||
"""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
@@ -1,45 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,49 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,52 +0,0 @@
|
||||
"""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
|
||||
@@ -1,52 +0,0 @@
|
||||
"""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
|
||||
@@ -1,43 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,25 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,53 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Warstwa BAZODANOWA — usługa HTTP.
|
||||
|
||||
Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszukać
|
||||
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
|
||||
ani prezentacji.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.config import settings
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
from app.providers.factory import build_provider
|
||||
|
||||
provider = build_provider(settings)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
provider.warmup() # zbuduj/odśwież indeks i cache przy starcie
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.post("/search", response_model=SearchResult)
|
||||
def search(query: SearchQuery) -> SearchResult:
|
||||
return provider.search(query)
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthInfo)
|
||||
def health() -> HealthInfo:
|
||||
return provider.health()
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Kontrakt danych warstwy bazodanowej.
|
||||
|
||||
Te modele są JEDYNYM publicznym interfejsem tej warstwy. Warstwa logiczna zna
|
||||
wyłącznie te kształty (poprzez HTTP/JSON) — nie wie nic o Excelu, cache ani SQL.
|
||||
Dzięki temu można podmienić implementację (Excel -> SQL) bez zmiany pozostałych
|
||||
warstw.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchQuery(BaseModel):
|
||||
"""Znormalizowane zapytanie wyszukiwania przychodzące z warstwy logicznej."""
|
||||
|
||||
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||
value: str = Field(..., description="Szukana wartość.")
|
||||
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
||||
limit: int = Field(50, ge=1, le=1000)
|
||||
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)
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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
|
||||
@@ -1,143 +0,0 @@
|
||||
"""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():
|
||||
self._ensure_indexed(path)
|
||||
|
||||
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:
|
||||
mask = col.str.lower().str.contains(query.value.lower(), na=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)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""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)})
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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
|
||||
@@ -1,43 +0,0 @@
|
||||
"""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}")
|
||||
@@ -1,10 +0,0 @@
|
||||
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"]
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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`
|
||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||
|
||||
## 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.
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Klient HTTP do warstwy bazodanowej.
|
||||
|
||||
Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
|
||||
(Excel→SQL), tutaj nie zmienia się NIC — kontrakt /search jest stały.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class DataClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||
|
||||
def search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/search", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.get(f"{self.base_url}/health")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
@@ -1,18 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,35 +0,0 @@
|
||||
"""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
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@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.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
|
||||
@@ -1,25 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,43 +0,0 @@
|
||||
"""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"),
|
||||
},
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
httpx>=0.28
|
||||
pydantic>=2.10
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,21 +0,0 @@
|
||||
# Warstwa prezentacji (`presentation`)
|
||||
|
||||
Niezależna usługa serwująca stronę WWW (formularz + tabela wyników). **W dół**
|
||||
przekazuje dane z formularza do warstwy logicznej i renderuje opracowane wyniki.
|
||||
Brak logiki biznesowej i dostępu do danych.
|
||||
|
||||
## Trasy
|
||||
- `GET /` — strona z formularzem
|
||||
- `POST /` — wysłanie formularza → warstwa logiczna → render wyników
|
||||
- `GET /health`
|
||||
|
||||
## Zależności w dół
|
||||
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej).
|
||||
|
||||
## Uruchomienie
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
export LOGIC_URL=http://localhost:8001
|
||||
uvicorn app.main:app --port 8000
|
||||
# otwórz http://localhost:8000
|
||||
```
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Klient HTTP do warstwy logicznej.
|
||||
|
||||
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
|
||||
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class LogicClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
||||
|
||||
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/api/query", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Konfiguracja warstwy prezentacji.
|
||||
|
||||
Zna TYLKO adres warstwy logicznej (w dół). Nie wie nic o bazie/Excelu/SQL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
|
||||
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
|
||||
|
||||
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
|
||||
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.clients.logic_client import LogicClient
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
logic = LogicClient()
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
return templates.TemplateResponse(request, "index.html", {"result": None, "form": {}})
|
||||
|
||||
|
||||
@app.post("/", response_class=HTMLResponse)
|
||||
def search(
|
||||
request: Request,
|
||||
query: str = Form(...),
|
||||
field: str = Form("name"),
|
||||
exact: bool = Form(False),
|
||||
limit: int = Form(25),
|
||||
):
|
||||
form = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||
ctx: dict = {"form": form, "result": None, "error": None}
|
||||
try:
|
||||
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
||||
except httpx.HTTPError as e:
|
||||
ctx["error"] = f"Warstwa logiczna niedostępna: {e}"
|
||||
return templates.TemplateResponse(request, "index.html", ctx)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "layer": "presentation"}
|
||||
@@ -1,35 +0,0 @@
|
||||
:root {
|
||||
--bg: #0f1020;
|
||||
--panel: #1a1c33;
|
||||
--ink: #e8e8f0;
|
||||
--muted: #9aa0c0;
|
||||
--accent: #8b7bf0;
|
||||
--line: #2a2d4a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; justify-content: center; padding: 3rem 1rem;
|
||||
}
|
||||
main { width: 100%; max-width: 880px; }
|
||||
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||
.row { display: flex; gap: .5rem; }
|
||||
.row input[type=text] { flex: 1; }
|
||||
input, select, button {
|
||||
font: inherit; padding: .6rem .75rem; border-radius: 8px;
|
||||
border: 1px solid var(--line); background: #12132a; color: var(--ink);
|
||||
}
|
||||
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
.empty { color: var(--muted); }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
@@ -1,62 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>astrololo</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>astrololo</h1>
|
||||
<p class="sub">Warstwa prezentacji → logiczna → bazodanowa</p>
|
||||
|
||||
<form method="post" action="/">
|
||||
<div class="row">
|
||||
<input type="text" name="query" placeholder="Szukana fraza…"
|
||||
value="{{ form.query or '' }}" autofocus required>
|
||||
<select name="field">
|
||||
{% for f in ["name", "id", "symbol", "category", "value"] %}
|
||||
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Szukaj</button>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="exact" value="true"
|
||||
{{ 'checked' if form.exact else '' }}> dokładne</label>
|
||||
<label>limit
|
||||
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="meta">
|
||||
Znaleziono <strong>{{ result.count }}</strong> ·
|
||||
provider: {{ result.meta.data_provider }} ·
|
||||
cache: {{ result.meta.data_cache }} ·
|
||||
{{ result.meta.data_elapsed_ms }} ms
|
||||
</div>
|
||||
{% if result.results %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in result.results %}
|
||||
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">Brak wyników.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
httpx>=0.28
|
||||
jinja2>=3.1
|
||||
python-multipart>=0.0.20
|
||||
Reference in New Issue
Block a user