Compare commits

..

1 Commits

Author SHA1 Message Date
gitea 16d35c16dc Szkielet aplikacji trójwarstwowej (prezentacja / logika / dane)
Trzy niezależne usługi FastAPI komunikujące się przez HTTP/JSON, każda
zna tylko adres warstwy bezpośrednio pod nią:

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:35:09 +02:00
55 changed files with 1491 additions and 285 deletions
+17
View File
@@ -0,0 +1,17 @@
# Skopiuj do .env i dostosuj.
# --- warstwa bazodanowa ---
DATA_PROVIDER=excel # excel | sql
EXCEL_DIR=./services/data/data_files
CACHE_DIR=./services/data/.cache
INDEXED_KEYS=name,id,symbol
HEADER_SCAN_ROWS=15
QUERY_CACHE_SIZE=512
QUERY_CACHE_TTL=300
SQL_URL=sqlite:///./.cache/astrololo.db
# --- warstwa logiczna ---
DATA_URL=http://localhost:8002
# --- warstwa prezentacji ---
LOGIC_URL=http://localhost:8001
+21
View File
@@ -0,0 +1,21 @@
# 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/
+38
View File
@@ -0,0 +1,38 @@
.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
+68 -1
View File
@@ -1 +1,68 @@
# 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, 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 10100× szybsze niż `.xlsx`.
3. **Zapytania (L3, in-memory TTL/LRU)** — powtarzalne wyszukiwania natychmiast (łatwo podmienić na Redis).
4. **Odwrócony indeks (L4, SQLite)**`wartość → plik`; otwieramy tylko trafione pliki zamiast skanu setek.
Unieważnianie automatyczne: klucz cache = **odcisk pliku** (`mtime+rozmiar`,
opcjonalnie `sha256`). Zmiana pliku → przebudowa tylko jego wpisów.
## Droga na przyszłość — migracja do SQL
Warstwa bazodanowa ukrywa źródło za interfejsem `DataProvider` (wzorzec
Repository). Migracja:
```bash
make migrate # ETL: tym samym loaderem Excel -> tabela 'records' + indeksy
export DATA_PROVIDER=sql # przełącz całą warstwę
```
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
+34
View File
@@ -0,0 +1,34 @@
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.
-110
View File
@@ -1,110 +0,0 @@
# 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ą **AGPL3.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`) | Defacto standard: pozycje obiektów, domy, aspekty, gwiazdy stałe, lots — pełna astrologia, najwyższa dokładność | **Dual: AGPL3.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 | **AGPL3.0** | 🔴 To prawdopodobnie „kalkulator z GitHuba", o którym wiesz. Sam wrapper jest AGPL niezależnie od licencji silnika. |
| **swissephwasm** (`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 (researchgrade), 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 15502650; DE441: 13200 do +17191. |
| **Moshier ephemeris** (Steve Moshier, `moshier.net`) | Semianalityczna 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) | **BSD3** | 🟢 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 | **AGPL3.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 | **GPL3.0+** | 🔴 GPL (sieciowo łagodniejsze niż AGPL, ale wciąż copyleft); ciągnie AGPLowy 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 | **AGPL3.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 / frontend (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 | opensource (zweryfikować plik LICENSE) | 🟡 Brak obliczeń = brak ryzyka efemeryd. Sprawdzić dokładnie licencję przed użyciem. |
| **swissephwasm** | (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 (timezoneboundarybuilder, ODbL) — przy dystrybucji bazy wymagana atrybucja i klauzula sharealike **dla samej bazy** (nie dla Twojego kodu). |
| **GeoNames** (baza miejscowości) | Miejscowości + współrzędne + strefy (do dropdowna lokalizacji) | **CCBY 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`) | **GPL2.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** (opensource) | 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ń (**Q01**: produkt zamknięty/hostowany czy nie) i **Q07** (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 longterm)**
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ąć AGPLowy 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ąć AGPLowych 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/) · [MoshierEphemerisJS](https://github.com/0xStarcat/Moshier-Ephemeris-JS)
- [CircularNatalHoroscopeJS](https://github.com/0xStarcat/CircularNatalHoroscopeJS) · [AstroChart (`Kibo/AstroChart`)](https://github.com/Kibo/AstroChart) · [swissephwasm](https://github.com/prolaxu/swisseph-wasm)
- [timezonefinder (PyPI, MIT)](https://pypi.org/project/timezonefinder/) · [Astrolog (Wikipedia)](https://en.wikipedia.org/wiki/Astrolog)
-174
View File
@@ -1,174 +0,0 @@
# Warstwa logiczna — analiza, rozwinięcie wymagań i przypisanie rozwiązań
Dokument roboczy. Rozwinięcie 24 wymagań warstwy logicznej (LOG01…LOG24 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: **~7080% 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 |
|----|-----------|-------------------------------|-------------------|----------|
| LOG01 | Pozycje obiektów | **Skyfield** (komplet) | Niska | walidacja pozycji |
| LOG02 | Taksonomia obiektów | Skyfield (planety, Księżyc), Hipparcos (gwiazdy) | **Wysoka** (true Node/Lilith, gwiazdy) | walidacja węzłów/Lilith |
| LOG03 | Kierunek i stacje | Skyfield (find_discrete) | Średnia | walidacja dat stacji |
| LOG04 | Systemy zodiaku / ayanamsy | Skyfield (tropikalny, RA), pyerfa (precesja) | Średnia | walidacja ayanams |
| LOG05 | Systemy domów (wiele) | pyerfa (LST, obliquity); reszta własna | **Wysoka** | walidacja cusps (kluczowa) |
| LOG06 | Aspekty + orby | własne (czysta matematyka) | Niska | walidacja list aspektów |
| LOG07 | Aspekty pozazodiakalne | Skyfield (deklinacja) | Niska | walidacja |
| LOG08 | Lots | własne (arytmetyka + DSL formuł) | Niska–Średnia | porównanie wartości |
| LOG09 | **Primary Directions** | brak permisywnego | **Bardzo wysoka (XL)** | **krytyczna** (Morinus) |
| LOG10 | Profekcje | własne (arytmetyka + tabele władców) | Niska–Średnia | tabela kontrolna |
| LOG11 | Zod. Releasing / Firdaria / Decennials | brak permisywnego | Średnia (tabledriven) | walidacja sekwencji |
| LOG12 | Returns (Solar/Lunar) | Skyfield (rootfinding) | Niska–Średnia | walidacja czasu powrotu |
| LOG13 | Ascensional Times | pyerfa/własne (oblique ascension) | Średnia | tabela kontrolna |
| LOG14 | Zbiorcza tabela dat | własne (orkiestracja) | Średnia | — |
| LOG15 | **Parser sygnifikatorów** | Lark/pyparsing (infrastruktura) | **Wysoka** (unikalne IP) | brak referencji |
| LOG16 | Precompute „atomów" | własne | Średnia | — |
| LOG17 | Interpretacja „asp"/„asp±" | własne (config + LOG06) | Niska–Średnia | — |
| LOG18 | Warstwy 1A/1B | własne (orkiestracja ↔ warstwa danych) | Średnia | — |
| LOG19 | Wypis interpretacji 2B | własne (↔ DataProvider) | Średnia | — |
| LOG20 | Kolumny sygnif. + „no of hits" | własne | Niska–Średnia | — |
| LOG21 | Scoring siły efektu | własne (rule/datadriven) | Średnia | — |
| LOG22 | Konwersja tekst ↔ symbol | własne (tablice z notes3) | Niska | — |
| LOG23 | Pozostałe wyliczenia | Skyfield (zdarzenia) | Niska | walidacja |
| LOG24 | Abstrakcja silnika efemeryd | architektura | Niska | — |
**Tylko 3 pozycje są naprawdę trudne:** LOG09 (primary directions, XL), LOG05 (pełny zestaw domów, Wysoka), LOG15 (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 (LOG01, 02, 03, 24)
**LOG01 · Pozycje obiektów.**
*Rozwinięcie:* długość i szerokość ekliptyczna (ofdate), prędkość (różniczkowanie pozycji), kierunek; formaty: DMS w znaku (Tau 28°12'57''), absolutne 0360°, 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″.
**LOG02 · 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; **ŚredniaWysoka**).
- **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 LOG05.
*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!).
**LOG03 · 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.
**LOG24 · Abstrakcja silnika.**
*Rozwinięcie:* interfejs `EphemerisEngine` z implementacjami `SkyfieldEngine` (główna, MIT+JPL), `MoshierEngine` (fallback w pełni publicdomain, 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 (LOG04, 05)
**LOG04 · Systemy zodiaku / ayanamsy.**
*Rozwinięcie:* tropikalny (domyślny, 🟢), syderyczny = tropikalny ayanamsa(t) (Lahiri, FaganBradley… ~40 w SE; my zaczynamy od kilku + tabela rozszerzalna), draconic = tropikalny długość NN (🟢 po LOG02), 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.
**LOG05 · 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 (LOG06, 07)
**LOG06 · 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, ±%, perplaneta/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.
**LOG07 · Aspekty pozazodiakalne.**
*Rozwinięcie:* parallel/contraparallel (po deklinacji — Skyfield 🟢), antiscia/contraantiscia (lustro względem osi przesileń — arytmetyka). *Złożoność: Niska.*
### Grupa D — Lots (LOG08)
**LOG08 · 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 Q03). *B/C:* porównanie wartości stopni.
### Grupa E — Techniki predykcyjne (LOG09…14)
**LOG09 · Primary Directions.** ⚠️ **najtrudniejsze (XL).**
*Rozwinięcie:* łuk dyrekcji (semiarc/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 58 (Q02), 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).
**LOG10 · 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).
**LOG11 · 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.
**LOG12 · Returns (Solar/Lunar).**
*Rozwinięcie:* rootfinding 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 LOG01…06. *Złożoność: Niska–Średnia.* *B/C:* czas powrotu z astro.com.
**LOG13 · 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 datadriven (tabela DAN07). *Ścieżka A:* wzory powiązane z LOG05 (trygonometria sferyczna) + pyerfa. *Złożoność: Średnia.*
**LOG14 · 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 rootfinding z LOG12). *Ścieżka A:* orkiestracja/glue. *Złożoność: Średnia.*
### Grupa F — Parser sygnifikatorów i dopasowanie (LOG15…20) — **rdzeń unikalnego IP**
**LOG15 · 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, „rAsc", whole sign+orb); 7 poziomów złożoności — od poziomu „obiekt" po sygnifikatory opisowe/niejasne (poziomy 57 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.
**LOG16 · 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, rAsc Ari…); dopasowanie rekordu bazy = przynależność do zbioru. Zależy od LOG01…08 (policzony horoskop) + LOG15 (słownik). *Złożoność: Średnia* (generacja kombinatoryczna + indeks; wrażliwe wydajnościowo — łączy się z DAN22).
**LOG17 · Interpretacja „asp"/„asp+"/„asp"/„asp malefic".**
*Rozwinięcie:* rozwinięcie wg konfigurowalnej tabeli pomocniczej (jakie aspekty, orby, active) + LOG06. *Złożoność: Niska–Średnia.*
**LOG18/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 / asisinchart), „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 (LOG21, 22, 23)
**LOG21 · 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).
**LOG22 · 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 Q05 (prezentacja).
**LOG23 · 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 SuMo), syzygia prenatalna (rootfind 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):** LOG24 (interfejs) → LOG01 → LOG02 (bez gwiazd/trueLilith) → LOG04 (tropikalny+RA) → LOG06/07 → LOG05 (Whole/Equal/Porphyry+Placidus) → LOG23 (podstawy). *Tu Skyfield daje ogromny skok.*
2. **Astrologia rdzenia:** LOG08 (Lots) → LOG15 (parser) → LOG16 → LOG18/19/20 (dopasowanie ↔ warstwa danych). *Tu powstaje unikalna wartość i pełny przepływ „horoskop → interpretacje".*
3. **Predykcja prosta:** LOG10 (profekcje) → LOG12 (returns) → LOG14 (agregacja) → LOG11 (timelordy) → LOG13.
4. **Predykcja trudna i dopieszczenie:** LOG09 (primary directions, XL) → LOG02 dokończenie (gwiazdy, true Node/Lilith) → LOG04 (ayanamsy) → LOG21 (scoring) → LOG05 (pełny zestaw domów).
Logika: najpierw to, co Skyfield daje tanio i co odblokowuje przepływ endtoend; 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):** **LOG25 — 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.
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8002
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]
+46
View File
@@ -0,0 +1,46 @@
# Warstwa bazodanowa (`data`)
Niezależna usługa. **Jedyne zadanie:** wyszukać dane i podać je w górę. Nie zna
warstwy logicznej ani prezentacji — komunikacja wyłącznie przez HTTP/JSON
(`models.py`).
## API
- `POST /search``SearchQuery``SearchResult`
- `GET /health``HealthInfo`
## Architektura wewnętrzna
```
providers/ wymienna implementacja (wzorzec Repository)
base.py interfejs DataProvider ← kontrakt
excel_provider dziś: Excel + 4 poziomy cache
sql_provider jutro: SQL (ten sam interfejs)
factory.py DATA_PROVIDER=excel|sql
excel/ wykrywanie nagłówka + mapowanie układu kolumn (jedyne miejsce znające .xlsx)
cache/ fingerprint, schema(L1), frame/parquet(L2), query(L3), index(L4)
ingest/ build_index.py (warmup), to_sql.py (migracja ETL)
```
## Cache — dlaczego szybko
| Poziom | Co cache'uje | Zysk |
|-------|---------------|------|
| L1 `schema.db` | wykryty nagłówek + układ kolumn per plik | brak ponownego skanu heurystyką |
| L2 Parquet | znormalizowany arkusz | 10100× szybciej niż parsowanie `.xlsx` |
| L3 `QueryCache` | wynik zapytania (TTL/LRU) | powtarzalne zapytania natychmiast |
| L4 `index.db` | odwrócony indeks wartość→plik | otwieramy tylko trafione pliki, nie setki |
Unieważnianie: klucz = odcisk pliku (`mtime+rozmiar`, opcjonalnie `sha256`).
Zmiana pliku → inny odcisk → automatyczny przebudowa.
## Uruchomienie lokalne
```bash
pip install -r requirements.txt
python scripts/make_sample_data.py # przykładowe .xlsx
python -m app.ingest.build_index # (opcjonalnie) prebuild indeksu
uvicorn app.main:app --port 8002
```
## Migracja do SQL (gdy nadejdzie czas)
```bash
python -m app.ingest.to_sql # Excel -> tabela 'records' + indeksy
export DATA_PROVIDER=sql # przełącz warstwę — reszta systemu bez zmian
```
View File
View File
+22
View File
@@ -0,0 +1,22 @@
"""Odcisk pliku = klucz unieważniania cache.
Wszystkie warstwy cache są kluczowane odciskiem pliku. Gdy plik Excela się zmieni,
zmienia się odcisk -> automatyczny "cache miss" i przebudowa. Domyślnie używamy
taniego (mtime + rozmiar); sha256 dostępne, gdy potrzeba pewności co do treści.
"""
from __future__ import annotations
import hashlib
import os
def fingerprint(path: str, strong: bool = False) -> str:
if strong:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return "sha256:" + h.hexdigest()[:16]
st = os.stat(path)
return f"mt:{int(st.st_mtime)}:{st.st_size}"
+35
View File
@@ -0,0 +1,35 @@
"""Cache danych: znormalizowany arkusz zapisany jako Parquet.
POZIOM 2 cache — największy zysk wydajności. Parsowanie .xlsx jest wolne
(dziesiątkisetki ms na duży plik). Po pierwszym wczytaniu zapisujemy
znormalizowaną ramkę jako Parquet (kolumnowy, kompresowany), kluczowaną odciskiem
pliku. Kolejne odczyty ładują Parquet — zwykle 10100x szybciej niż .xlsx i bez
ponownego wykrywania nagłówka.
Plik Parquet jest też naturalnym formatem pośrednim przy migracji do SQL.
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
class FrameCache:
def __init__(self, cache_dir: Path) -> None:
self.dir = cache_dir / "frames"
self.dir.mkdir(parents=True, exist_ok=True)
def _path(self, fp: str, sheet: str) -> Path:
safe = fp.replace(":", "_") + "__" + str(sheet).replace("/", "_")
return self.dir / f"{safe}.parquet"
def get(self, fp: str, sheet: str) -> pd.DataFrame | None:
p = self._path(fp, sheet)
if p.exists():
return pd.read_parquet(p)
return None
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
frame.to_parquet(self._path(fp, sheet), index=False)
+68
View File
@@ -0,0 +1,68 @@
"""Odwrócony indeks: wartość kanonicznego klucza -> które pliki/arkusze ją mają.
POZIOM 4 (i najważniejszy przy skali) — pozwala NIE skanować setek plików przy
każdym zapytaniu. Budujemy w SQLite indeks: dla wybranych kluczy (np. name, id,
symbol) zapisujemy, w którym pliku/arkuszu występuje dana wartość. Wyszukiwanie
najpierw pyta indeks (jeden szybki SELECT), a otwiera tylko trafione pliki.
Ten SQLite indeks jest jednocześnie POMOSTEM do pełnej migracji SQL — rozbudowa
go o wszystkie kolumny = de facto baza danych (patrz ingest/to_sql.py).
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
class InvertedIndex:
def __init__(self, cache_dir: Path) -> None:
self._db = sqlite3.connect(str(cache_dir / "index.db"), check_same_thread=False)
self._db.execute(
"""
CREATE TABLE IF NOT EXISTS entries (
key TEXT, -- kanoniczne pole, np. 'name'
value TEXT, -- znormalizowana (lower) wartość
file TEXT, -- ścieżka pliku
sheet TEXT
)
"""
)
self._db.execute("CREATE INDEX IF NOT EXISTS ix_kv ON entries(key, value)")
self._db.execute(
"CREATE TABLE IF NOT EXISTS files (file TEXT PRIMARY KEY, fingerprint TEXT)"
)
self._db.commit()
def file_fingerprint(self, file: str) -> str | None:
cur = self._db.execute("SELECT fingerprint FROM files WHERE file=?", (file,))
row = cur.fetchone()
return row[0] if row else None
def reindex_file(self, file: str, fingerprint: str, rows: list[tuple[str, str, str]]) -> None:
"""rows: lista (key, value, sheet) dla jednego pliku."""
self._db.execute("DELETE FROM entries WHERE file=?", (file,))
self._db.executemany(
"INSERT INTO entries(key, value, file, sheet) VALUES (?,?,?,?)",
[(k, v.lower(), file, sheet) for (k, v, sheet) in rows],
)
self._db.execute(
"INSERT OR REPLACE INTO files(file, fingerprint) VALUES (?,?)", (file, fingerprint)
)
self._db.commit()
def lookup(self, key: str, value: str, exact: bool) -> list[tuple[str, str]]:
"""Zwraca listę (file, sheet) kandydatów do przeszukania."""
if exact:
cur = self._db.execute(
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value=?",
(key, value.lower()),
)
else:
cur = self._db.execute(
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value LIKE ?",
(key, f"%{value.lower()}%"),
)
return [(r[0], r[1]) for r in cur.fetchall()]
def count_files(self) -> int:
return self._db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
+39
View File
@@ -0,0 +1,39 @@
"""Cache wyników zapytań: in-memory LRU + TTL.
POZIOM 3 cache. Te same zapytania powtarzają się (popularne wyszukiwania). Tu
trzymamy gotowy wynik przez krótki TTL. Bez zewnętrznych zależności — w produkcji
można podmienić na Redis (ten sam interfejs get/put), by współdzielić cache
między instancjami.
"""
from __future__ import annotations
import time
from collections import OrderedDict
from typing import Any
class QueryCache:
def __init__(self, max_size: int = 512, ttl: int = 300) -> None:
self.max_size = max_size
self.ttl = ttl
self._store: OrderedDict[str, tuple[float, Any]] = OrderedDict()
def get(self, key: str) -> Any | None:
item = self._store.get(key)
if item is None:
return None
ts, value = item
if time.time() - ts > self.ttl:
del self._store[key]
return None
self._store.move_to_end(key)
return value
def put(self, key: str, value: Any) -> None:
self._store[key] = (time.time(), value)
self._store.move_to_end(key)
while len(self._store) > self.max_size:
self._store.popitem(last=False)
def clear(self) -> None:
self._store.clear()
+45
View File
@@ -0,0 +1,45 @@
"""Cache schematu: wykryty wiersz nagłówka + mapowanie kolumn, per (plik, arkusz).
POZIOM 1 cache. Najdroższe jest samo wykrywanie nagłówka i wnioskowanie układu
kolumn. Robimy to RAZ na wersję pliku i zapisujemy w SQLite. Kolejne odczyty
pomijają skanowanie.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
class SchemaCache:
def __init__(self, cache_dir: Path) -> None:
self._db = sqlite3.connect(str(cache_dir / "schema.db"), check_same_thread=False)
self._db.execute(
"""
CREATE TABLE IF NOT EXISTS schema_cache (
fingerprint TEXT,
sheet TEXT,
header_row INTEGER,
mapping TEXT,
PRIMARY KEY (fingerprint, sheet)
)
"""
)
self._db.commit()
def get(self, fp: str, sheet: str) -> tuple[int, dict[str, str]] | None:
cur = self._db.execute(
"SELECT header_row, mapping FROM schema_cache WHERE fingerprint=? AND sheet=?",
(fp, sheet),
)
row = cur.fetchone()
if row is None:
return None
return int(row[0]), json.loads(row[1])
def put(self, fp: str, sheet: str, header_row: int, mapping: dict[str, str]) -> None:
self._db.execute(
"INSERT OR REPLACE INTO schema_cache VALUES (?,?,?,?)",
(fp, sheet, header_row, json.dumps(mapping, ensure_ascii=False)),
)
self._db.commit()
+49
View File
@@ -0,0 +1,49 @@
"""Konfiguracja warstwy bazodanowej (z ENV).
Najważniejsza zmienna: DATA_PROVIDER. Zmiana 'excel' -> 'sql' przełącza całą
warstwę na bazę SQL bez dotykania pozostałych modułów (patrz providers/factory.py).
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent # .../services/data
@dataclass
class Settings:
# 'excel' (dziś) albo 'sql' (po migracji)
provider: str = field(default_factory=lambda: os.getenv("DATA_PROVIDER", "excel"))
# Warstwa Excel
excel_dir: Path = field(
default_factory=lambda: Path(os.getenv("EXCEL_DIR", str(BASE_DIR / "data_files")))
)
cache_dir: Path = field(
default_factory=lambda: Path(os.getenv("CACHE_DIR", str(BASE_DIR / ".cache")))
)
header_scan_rows: int = field(default_factory=lambda: int(os.getenv("HEADER_SCAN_ROWS", "15")))
# Klucze kanoniczne, które trafiają do odwróconego indeksu (przyspiesza wyszukiwanie).
indexed_keys: tuple[str, ...] = field(
default_factory=lambda: tuple(
k.strip() for k in os.getenv("INDEXED_KEYS", "name,id,symbol").split(",") if k.strip()
)
)
# Cache zapytań (in-memory)
query_cache_size: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_SIZE", "512")))
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
# Warstwa SQL (po migracji)
sql_url: str = field(
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
)
def __post_init__(self) -> None:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.excel_dir.mkdir(parents=True, exist_ok=True)
settings = Settings()
View File
+52
View File
@@ -0,0 +1,52 @@
"""Wykrywanie wiersza nagłówka w arkuszu.
Pliki Excela w tej domenie nie mają nagłówka zawsze w pierwszym wierszu — bywają
puste wiersze, tytuły, metadane. Ta heurystyka skanuje pierwsze N wierszy i
wybiera ten, który "wygląda jak nagłówek": dużo niepustych, tekstowych,
unikalnych komórek, po którym następują wiersze danych o podobnym wypełnieniu.
Wynik (indeks wiersza nagłówka) jest CACHE'OWANY per plik (schema_cache), więc
ten kosztowny skan robimy raz na wersję pliku.
"""
from __future__ import annotations
import pandas as pd
def _row_score(raw: pd.DataFrame, i: int) -> float:
row = raw.iloc[i]
non_null = row.notna()
filled = float(non_null.mean()) if len(row) else 0.0
if filled == 0:
return -1.0
values = [str(v).strip() for v in row[non_null].tolist()]
text_like = sum(1 for v in values if v and not _looks_numeric(v))
text_ratio = text_like / max(len(values), 1)
uniqueness = len(set(values)) / max(len(values), 1)
# Wiersze danych pod spodem powinny mieć podobną liczbę wypełnionych kolumn.
follow_bonus = 0.0
if i + 1 < len(raw):
below = raw.iloc[i + 1].notna().mean()
follow_bonus = 1.0 - abs(filled - float(below))
return filled * 0.4 + text_ratio * 0.3 + uniqueness * 0.2 + follow_bonus * 0.1
def _looks_numeric(v: str) -> bool:
try:
float(v.replace(",", "."))
return True
except ValueError:
return False
def detect_header_row(raw: pd.DataFrame, max_scan: int = 15) -> int:
"""Zwraca indeks (0-based) wiersza, który najprawdopodobniej jest nagłówkiem."""
best_idx, best_score = 0, float("-inf")
for i in range(min(max_scan, len(raw))):
score = _row_score(raw, i)
if score > best_score:
best_idx, best_score = i, score
return best_idx
+52
View File
@@ -0,0 +1,52 @@
"""Mapowanie układu kolumn na schemat kanoniczny.
Setki plików mogą mieć te same dane pod różnymi nagłówkami i w różnej kolejności
kolumn ("Imię", "Name", "NAZWA" -> kanoniczne 'name'). Ta warstwa tłumaczy
faktyczny układ kolumn pliku na wspólny słownik pól, dzięki czemu reszta systemu
(i przyszła baza SQL) operuje na jednej, stabilnej nazwie pola.
Aliasowanie jest świadomie wydzielone i konfigurowalne — to jedyne miejsce do
edycji, gdy pojawi się nowy wariant nagłówka.
"""
from __future__ import annotations
import re
# kanoniczne_pole -> zbiór aliasów (po normalizacji)
CANONICAL_ALIASES: dict[str, set[str]] = {
"id": {"id", "identyfikator", "nr", "no", "number"},
"name": {"name", "imie", "nazwa", "nazwisko", "title", "tytul"},
"symbol": {"symbol", "znak", "sign", "glyph"},
"date": {"date", "data", "datetime", "timestamp"},
"value": {"value", "wartosc", "val", "amount", "kwota"},
"category": {"category", "kategoria", "type", "typ", "group", "grupa"},
}
def _normalize(col: str) -> str:
s = str(col).strip().lower()
s = re.sub(r"[ąàá]", "a", s)
s = s.replace("ł", "l").replace("ż", "z").replace("ź", "z").replace("ć", "c")
s = s.replace("ę", "e").replace("ó", "o").replace("ś", "s").replace("ń", "n")
s = re.sub(r"[^a-z0-9]+", "", s)
return s
def build_column_mapping(header_cells: list[str]) -> dict[str, str]:
"""Zwraca mapę pole_kanoniczne -> faktyczna_nazwa_kolumny dla danego pliku.
Kolumny nierozpoznane są zachowywane pod swoją (znormalizowaną) nazwą, więc
nic nie ginie — po prostu nie mają aliasu kanonicznego.
"""
reverse: dict[str, str] = {}
for canonical, aliases in CANONICAL_ALIASES.items():
for alias in aliases:
reverse[alias] = canonical
mapping: dict[str, str] = {}
for actual in header_cells:
norm = _normalize(actual)
canonical = reverse.get(norm, norm or "col")
# pierwsze trafienie wygrywa (stabilność przy duplikatach)
mapping.setdefault(canonical, actual)
return mapping
+43
View File
@@ -0,0 +1,43 @@
"""Wczytanie pojedynczego arkusza do znormalizowanej ramki danych.
Łączy wykrywanie nagłówka (header_detect) z mapowaniem układu kolumn (layout).
Zwraca ramkę o KANONICZNYCH nazwach kolumn — gotową do indeksowania, cache'owania
(parquet) i ewentualnego załadowania do SQL.
To jest jedyne miejsce, które "rozumie" format Excela. Reszta systemu jej nie
widzi.
"""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
from app.excel.header_detect import detect_header_row
from app.excel.layout import build_column_mapping
@dataclass
class LoadedSheet:
frame: pd.DataFrame # dane z kanonicznymi kolumnami
header_row: int # wykryty indeks nagłówka
column_mapping: dict[str, str] # pole_kanoniczne -> oryginalna_nazwa
def load_sheet(path: str, sheet: str | int = 0, header_scan_rows: int = 15) -> LoadedSheet:
raw = pd.read_excel(path, sheet_name=sheet, header=None, dtype=object)
header_row = detect_header_row(raw, max_scan=header_scan_rows)
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
mapping = build_column_mapping(header_cells)
data = raw.iloc[header_row + 1 :].copy()
data.columns = header_cells
data = data.dropna(how="all")
# przenazwij na kanoniczne pola: {oryginał -> kanoniczne}
inverse = {orig: canon for canon, orig in mapping.items()}
data = data.rename(columns=inverse)
data = data.reset_index(drop=True)
return LoadedSheet(frame=data, header_row=header_row, column_mapping=mapping)
+25
View File
@@ -0,0 +1,25 @@
"""Wstępne zbudowanie cache + odwróconego indeksu dla wszystkich plików Excela.
Uruchom raz po wgraniu/aktualizacji plików (albo zostaw warmup przy starcie usługi):
python -m app.ingest.build_index
"""
from __future__ import annotations
from app.config import settings
from app.providers.excel_provider import ExcelDataProvider
def main() -> None:
provider = ExcelDataProvider(settings)
files = provider._excel_files()
print(f"Indeksuję {len(files)} plików z {settings.excel_dir} ...")
for i, path in enumerate(files, 1):
provider._ensure_indexed(path)
if i % 25 == 0 or i == len(files):
print(f" {i}/{len(files)}")
print(f"Gotowe. Zaindeksowane pliki: {provider.index.count_files()}")
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
"""ETL migracji: setki plików Excela -> jedna zoptymalizowana tabela SQL.
To jest "łatwa droga na przyszłość". Skrypt używa DOKŁADNIE tego samego loadera
co warstwa Excela (wykrywanie nagłówka + mapowanie kolumn kanonicznych), więc
dane trafiają do SQL już znormalizowane i spójne. Po załadowaniu wystarczy
ustawić DATA_PROVIDER=sql.
python -m app.ingest.to_sql
Kroki:
1. wczytaj każdy plik loaderem -> ramka o kanonicznych kolumnach,
2. dołóż kolumnę źródła (_source_file) dla audytu,
3. dopisz do tabeli 'records',
4. załóż indeksy na kluczach kanonicznych (przyspieszenie zapytań).
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from sqlalchemy import create_engine, text
from app.config import settings
from app.excel.loader import load_sheet
def main() -> None:
engine = create_engine(settings.sql_url, future=True)
files = sorted(Path(settings.excel_dir).glob("**/*.xlsx"))
print(f"Migruję {len(files)} plików -> {settings.sql_url}")
first = True
for path in files:
if path.name.startswith("~$"):
continue
loaded = load_sheet(str(path), header_scan_rows=settings.header_scan_rows)
frame = loaded.frame.copy()
frame["_source_file"] = path.name
frame.to_sql("records", engine, if_exists="replace" if first else "append", index=False)
first = False
with engine.connect() as conn:
for key in settings.indexed_keys:
try:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_records_{key} ON records({key})"))
except Exception as e: # kolumna może nie istnieć w tym zbiorze
print(f" (pomijam indeks {key}: {e})")
conn.commit()
print("Migracja zakończona. Ustaw DATA_PROVIDER=sql aby przełączyć warstwę.")
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
"""Warstwa BAZODANOWA — usługa HTTP.
Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszukać
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
ani prezentacji.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.factory import build_provider
provider = build_provider(settings)
@asynccontextmanager
async def lifespan(app: FastAPI):
provider.warmup() # zbuduj/odśwież indeks i cache przy starcie
yield
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
@app.post("/search", response_model=SearchResult)
def search(query: SearchQuery) -> SearchResult:
return provider.search(query)
@app.get("/health", response_model=HealthInfo)
def health() -> HealthInfo:
return provider.health()
+41
View File
@@ -0,0 +1,41 @@
"""Kontrakt danych warstwy bazodanowej.
Te modele są JEDYNYM publicznym interfejsem tej warstwy. Warstwa logiczna zna
wyłącznie te kształty (poprzez HTTP/JSON) — nie wie nic o Excelu, cache ani SQL.
Dzięki temu można podmienić implementację (Excel -> SQL) bez zmiany pozostałych
warstw.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class SearchQuery(BaseModel):
"""Znormalizowane zapytanie wyszukiwania przychodzące z warstwy logicznej."""
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
value: str = Field(..., description="Szukana wartość.")
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
limit: int = Field(50, ge=1, le=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)
+27
View File
@@ -0,0 +1,27 @@
"""Abstrakcyjny interfejs dostawcy danych (wzorzec Repository/Strategy).
To jest klucz do "łatwej migracji do SQL". Warstwa bazodanowa udostępnia na
zewnątrz tylko ten kontrakt. Dziś realizuje go ExcelDataProvider, jutro
SqlDataProvider — bez żadnej zmiany w warstwie logicznej i prezentacji.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from app.models import HealthInfo, SearchQuery, SearchResult
class DataProvider(ABC):
name: str = "base"
@abstractmethod
def search(self, query: SearchQuery) -> SearchResult:
"""Wyszuka dane i zwróci je w górę. JEDYNE zadanie tej warstwy."""
@abstractmethod
def health(self) -> HealthInfo:
...
def warmup(self) -> None:
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
return None
@@ -0,0 +1,143 @@
"""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)
+19
View File
@@ -0,0 +1,19 @@
"""Fabryka dostawcy danych — jedyne miejsce, które wie o konkretnych implementacjach.
Przełączenie Excel <-> SQL: ustaw DATA_PROVIDER w środowisku. Nic poza tym.
"""
from __future__ import annotations
from app.config import Settings
from app.providers.base import DataProvider
def build_provider(settings: Settings) -> DataProvider:
if settings.provider == "sql":
from app.providers.sql_provider import SqlDataProvider
return SqlDataProvider(settings)
from app.providers.excel_provider import ExcelDataProvider
return ExcelDataProvider(settings)
@@ -0,0 +1,47 @@
"""SqlDataProvider — implementacja docelowa (po migracji z Excela).
Szkielet. Realizuje TEN SAM interfejs DataProvider, więc przełączenie to tylko
zmiana zmiennej środowiskowej DATA_PROVIDER=sql (patrz factory.py). Warstwa
logiczna i prezentacji nie zmieniają ani jednej linii.
Dane ładuje do bazy skrypt ingest/to_sql.py (ten sam loader Excela -> tabele SQL).
"""
from __future__ import annotations
import time
from sqlalchemy import create_engine, text
from app.config import Settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.base import DataProvider
class SqlDataProvider(DataProvider):
name = "sql"
def __init__(self, settings: Settings) -> None:
self.s = settings
self.engine = create_engine(settings.sql_url, future=True)
def search(self, query: SearchQuery) -> SearchResult:
t0 = time.perf_counter()
op = "=" if query.exact else "LIKE"
val = query.value if query.exact else f"%{query.value}%"
cols = ", ".join(query.fields) if query.fields else "*"
# UWAGA: nazwy kolumn/tabel walidować względem białej listy schematu.
sql = text(f"SELECT {cols} FROM records WHERE {query.key} {op} :v LIMIT :lim")
with self.engine.connect() as conn:
rows = [dict(r._mapping) for r in conn.execute(sql, {"v": val, "lim": query.limit})]
return SearchResult(
rows=rows,
total=len(rows),
elapsed_ms=round((time.perf_counter() - t0) * 1000, 2),
cache="miss",
provider=self.name,
)
def health(self) -> HealthInfo:
with self.engine.connect() as conn:
n = conn.execute(text("SELECT COUNT(*) FROM records")).scalar() or 0
return HealthInfo(provider=self.name, indexed_files=0, details={"records": int(n)})
View File
+9
View File
@@ -0,0 +1,9 @@
# Dolne ograniczenia (>=) — działa zarówno na Pythonie 3.12 (obraz Docker),
# jak i na najnowszym 3.14 lokalnie. Przypnij dokładne wersje, gdy ustabilizujesz środowisko.
fastapi>=0.115
uvicorn[standard]>=0.34
pandas>=2.2
openpyxl>=3.1
pyarrow>=18.0
SQLAlchemy>=2.0
pydantic>=2.10
+43
View File
@@ -0,0 +1,43 @@
"""Generuje kilka przykładowych plików .xlsx do dema.
Celowo różnicuje: pozycję nagłówka (puste wiersze/tytuł nad nagłówkiem) oraz
kolejność i nazwy kolumn ("Imię"/"Name", "Symbol"/"Znak") — żeby pokazać działanie
wykrywania nagłówka i mapowania układu kolumn.
python scripts/make_sample_data.py
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
OUT = Path(__file__).resolve().parent.parent / "data_files"
OUT.mkdir(parents=True, exist_ok=True)
SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo"]
def file_a() -> None:
# nagłówek w 1. wierszu, nazwy PL
df = pd.DataFrame(
{"id": [1, 2, 3], "Imię": SIGNS[:3], "Symbol": ["", "", ""], "Wartość": [10, 20, 30]}
)
df.to_excel(OUT / "zodiac_pl.xlsx", index=False)
def file_b() -> None:
# tytuł + pusty wiersz nad nagłówkiem, nazwy EN, inna kolejność kolumn
with pd.ExcelWriter(OUT / "zodiac_en.xlsx") as xl:
meta = pd.DataFrame([["Tabela astrologiczna — wersja 2"], [None]])
meta.to_excel(xl, index=False, header=False, startrow=0)
df = pd.DataFrame(
{"Sign": ["", "", ""], "Name": SIGNS[3:], "No": [4, 5, 6], "Value": [40, 50, 60]}
)
df.to_excel(xl, index=False, startrow=2)
if __name__ == "__main__":
file_a()
file_b()
print(f"Zapisano przykładowe pliki w {OUT}")
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8001
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
+24
View File
@@ -0,0 +1,24 @@
# 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.
View File
+30
View File
@@ -0,0 +1,30 @@
"""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()
+18
View File
@@ -0,0 +1,18 @@
"""Konfiguracja warstwy logicznej.
Zna TYLKO adres warstwy bazodanowej (w dół). Nie wie nic o jej wnętrzu
(Excel/SQL/cache).
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass
class Settings:
data_url: str = field(default_factory=lambda: os.getenv("DATA_URL", "http://localhost:8002"))
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
settings = Settings()
+35
View File
@@ -0,0 +1,35 @@
"""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
+25
View File
@@ -0,0 +1,25 @@
"""Kontrakt warstwy logicznej (widziany przez warstwę prezentacji)."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
"""To, co przychodzi z formularza (przez warstwę prezentacji)."""
query: str = Field(..., min_length=1, description="Szukana fraza.")
field: str = Field("name", description="Po którym polu szukać.")
exact: bool = False
limit: int = Field(25, ge=1, le=200)
class QueryResponse(BaseModel):
"""To, co wraca w górę do prezentacji."""
status: str = "ok"
query: str
count: int
results: list[dict[str, Any]]
meta: dict[str, Any] = Field(default_factory=dict)
+43
View File
@@ -0,0 +1,43 @@
"""Logika biznesowa — serce warstwy logicznej.
Tu (a nie w prezentacji ani w bazie) żyją reguły: walidacja/normalizacja danych
z formularza, tłumaczenie zapytania użytkownika na znormalizowane zapytanie do
bazy, oraz opracowanie/wzbogacenie wyników w drodze w górę.
To jest miejsce do rozbudowy o właściwą domenę (obliczenia, reguły, agregacje).
"""
from __future__ import annotations
from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
class QueryService:
def __init__(self, data_client: DataClient | None = None) -> None:
self.data = data_client or DataClient()
def handle(self, req: QueryRequest) -> QueryResponse:
# 1) normalizacja wejścia z formularza (reguła biznesowa)
value = req.query.strip()
key = req.field.strip().lower()
# 2) zapytanie w dół do warstwy bazodanowej
raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
results = raw.get("rows", [])
results = sorted(results, key=lambda r: str(r.get(key, "")))
return QueryResponse(
status="ok",
query=value,
count=len(results),
results=results,
meta={
"field": key,
"exact": req.exact,
"data_cache": raw.get("cache"),
"data_provider": raw.get("provider"),
"data_elapsed_ms": raw.get("elapsed_ms"),
},
)
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
pydantic>=2.10
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+21
View File
@@ -0,0 +1,21 @@
# 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
```
@@ -0,0 +1,24 @@
"""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()
+17
View File
@@ -0,0 +1,17 @@
"""Konfiguracja warstwy prezentacji.
Zna TYLKO adres warstwy logicznej (w dół). Nie wie nic o bazie/Excelu/SQL.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass
class Settings:
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
settings = Settings()
+46
View File
@@ -0,0 +1,46 @@
"""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"}
@@ -0,0 +1,35 @@
: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; }
@@ -0,0 +1,62 @@
<!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>
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
jinja2>=3.1
python-multipart>=0.0.20