From 16d35c16dc6d8fe1587402eeee0242e1fbc6158b Mon Sep 17 00:00:00 2001 From: migatu Date: Fri, 26 Jun 2026 17:35:09 +0200 Subject: [PATCH] =?UTF-8?q?Szkielet=20aplikacji=20tr=C3=B3jwarstwowej=20(p?= =?UTF-8?q?rezentacja=20/=20logika=20/=20dane)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 17 +++ .gitignore | 21 +++ Makefile | 38 +++++ README.md | 69 ++++++++- docker-compose.yml | 34 +++++ services/data/Dockerfile | 10 ++ services/data/README.md | 46 ++++++ services/data/app/__init__.py | 0 services/data/app/cache/__init__.py | 0 services/data/app/cache/fingerprint.py | 22 +++ services/data/app/cache/frame_cache.py | 35 +++++ services/data/app/cache/index.py | 68 +++++++++ services/data/app/cache/query_cache.py | 39 +++++ services/data/app/cache/schema_cache.py | 45 ++++++ services/data/app/config.py | 49 ++++++ services/data/app/excel/__init__.py | 0 services/data/app/excel/header_detect.py | 52 +++++++ services/data/app/excel/layout.py | 52 +++++++ services/data/app/excel/loader.py | 43 ++++++ services/data/app/ingest/__init__.py | 0 services/data/app/ingest/build_index.py | 25 +++ services/data/app/ingest/to_sql.py | 53 +++++++ services/data/app/main.py | 36 +++++ services/data/app/models.py | 41 +++++ services/data/app/providers/__init__.py | 0 services/data/app/providers/base.py | 27 ++++ services/data/app/providers/excel_provider.py | 143 ++++++++++++++++++ services/data/app/providers/factory.py | 19 +++ services/data/app/providers/sql_provider.py | 47 ++++++ services/data/data_files/.gitkeep | 0 services/data/requirements.txt | 9 ++ services/data/scripts/make_sample_data.py | 43 ++++++ services/logic/Dockerfile | 10 ++ services/logic/README.md | 24 +++ services/logic/app/__init__.py | 0 services/logic/app/clients/__init__.py | 0 services/logic/app/clients/data_client.py | 30 ++++ services/logic/app/config.py | 18 +++ services/logic/app/main.py | 35 +++++ services/logic/app/models.py | 25 +++ services/logic/app/service.py | 43 ++++++ services/logic/requirements.txt | 4 + services/presentation/Dockerfile | 10 ++ services/presentation/README.md | 21 +++ services/presentation/app/__init__.py | 0 services/presentation/app/clients/__init__.py | 0 .../presentation/app/clients/logic_client.py | 24 +++ services/presentation/app/config.py | 17 +++ services/presentation/app/main.py | 46 ++++++ services/presentation/app/static/styles.css | 35 +++++ .../presentation/app/templates/index.html | 62 ++++++++ services/presentation/requirements.txt | 5 + 52 files changed, 1491 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 docker-compose.yml create mode 100644 services/data/Dockerfile create mode 100644 services/data/README.md create mode 100644 services/data/app/__init__.py create mode 100644 services/data/app/cache/__init__.py create mode 100644 services/data/app/cache/fingerprint.py create mode 100644 services/data/app/cache/frame_cache.py create mode 100644 services/data/app/cache/index.py create mode 100644 services/data/app/cache/query_cache.py create mode 100644 services/data/app/cache/schema_cache.py create mode 100644 services/data/app/config.py create mode 100644 services/data/app/excel/__init__.py create mode 100644 services/data/app/excel/header_detect.py create mode 100644 services/data/app/excel/layout.py create mode 100644 services/data/app/excel/loader.py create mode 100644 services/data/app/ingest/__init__.py create mode 100644 services/data/app/ingest/build_index.py create mode 100644 services/data/app/ingest/to_sql.py create mode 100644 services/data/app/main.py create mode 100644 services/data/app/models.py create mode 100644 services/data/app/providers/__init__.py create mode 100644 services/data/app/providers/base.py create mode 100644 services/data/app/providers/excel_provider.py create mode 100644 services/data/app/providers/factory.py create mode 100644 services/data/app/providers/sql_provider.py create mode 100644 services/data/data_files/.gitkeep create mode 100644 services/data/requirements.txt create mode 100644 services/data/scripts/make_sample_data.py create mode 100644 services/logic/Dockerfile create mode 100644 services/logic/README.md create mode 100644 services/logic/app/__init__.py create mode 100644 services/logic/app/clients/__init__.py create mode 100644 services/logic/app/clients/data_client.py create mode 100644 services/logic/app/config.py create mode 100644 services/logic/app/main.py create mode 100644 services/logic/app/models.py create mode 100644 services/logic/app/service.py create mode 100644 services/logic/requirements.txt create mode 100644 services/presentation/Dockerfile create mode 100644 services/presentation/README.md create mode 100644 services/presentation/app/__init__.py create mode 100644 services/presentation/app/clients/__init__.py create mode 100644 services/presentation/app/clients/logic_client.py create mode 100644 services/presentation/app/config.py create mode 100644 services/presentation/app/main.py create mode 100644 services/presentation/app/static/styles.css create mode 100644 services/presentation/app/templates/index.html create mode 100644 services/presentation/requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..61603a9 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..508bac4 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cea3734 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index c516701..64cc77d 100644 --- a/README.md +++ b/README.md @@ -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 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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..593cdcb --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/services/data/Dockerfile b/services/data/Dockerfile new file mode 100644 index 0000000..108b2b0 --- /dev/null +++ b/services/data/Dockerfile @@ -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"] diff --git a/services/data/README.md b/services/data/README.md new file mode 100644 index 0000000..cbf0fa1 --- /dev/null +++ b/services/data/README.md @@ -0,0 +1,46 @@ +# Warstwa bazodanowa (`data`) + +Niezależna usługa. **Jedyne zadanie:** wyszukać dane i podać je w górę. Nie zna +warstwy logicznej ani prezentacji — komunikacja wyłącznie przez HTTP/JSON +(`models.py`). + +## API +- `POST /search` → `SearchQuery` → `SearchResult` +- `GET /health` → `HealthInfo` + +## Architektura wewnętrzna +``` +providers/ wymienna implementacja (wzorzec Repository) + base.py interfejs DataProvider ← kontrakt + excel_provider dziś: Excel + 4 poziomy cache + sql_provider jutro: SQL (ten sam interfejs) + factory.py DATA_PROVIDER=excel|sql +excel/ wykrywanie nagłówka + mapowanie układu kolumn (jedyne miejsce znające .xlsx) +cache/ fingerprint, schema(L1), frame/parquet(L2), query(L3), index(L4) +ingest/ build_index.py (warmup), to_sql.py (migracja ETL) +``` + +## Cache — dlaczego szybko +| Poziom | Co cache'uje | Zysk | +|-------|---------------|------| +| L1 `schema.db` | wykryty nagłówek + układ kolumn per plik | brak ponownego skanu heurystyką | +| L2 Parquet | znormalizowany arkusz | 10–100× szybciej niż parsowanie `.xlsx` | +| L3 `QueryCache` | wynik zapytania (TTL/LRU) | powtarzalne zapytania natychmiast | +| L4 `index.db` | odwrócony indeks wartość→plik | otwieramy tylko trafione pliki, nie setki | + +Unieważnianie: klucz = odcisk pliku (`mtime+rozmiar`, opcjonalnie `sha256`). +Zmiana pliku → inny odcisk → automatyczny przebudowa. + +## Uruchomienie lokalne +```bash +pip install -r requirements.txt +python scripts/make_sample_data.py # przykładowe .xlsx +python -m app.ingest.build_index # (opcjonalnie) prebuild indeksu +uvicorn app.main:app --port 8002 +``` + +## Migracja do SQL (gdy nadejdzie czas) +```bash +python -m app.ingest.to_sql # Excel -> tabela 'records' + indeksy +export DATA_PROVIDER=sql # przełącz warstwę — reszta systemu bez zmian +``` diff --git a/services/data/app/__init__.py b/services/data/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/data/app/cache/__init__.py b/services/data/app/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/data/app/cache/fingerprint.py b/services/data/app/cache/fingerprint.py new file mode 100644 index 0000000..9a7dd26 --- /dev/null +++ b/services/data/app/cache/fingerprint.py @@ -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}" diff --git a/services/data/app/cache/frame_cache.py b/services/data/app/cache/frame_cache.py new file mode 100644 index 0000000..c110c14 --- /dev/null +++ b/services/data/app/cache/frame_cache.py @@ -0,0 +1,35 @@ +"""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) diff --git a/services/data/app/cache/index.py b/services/data/app/cache/index.py new file mode 100644 index 0000000..4bdb7b2 --- /dev/null +++ b/services/data/app/cache/index.py @@ -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] diff --git a/services/data/app/cache/query_cache.py b/services/data/app/cache/query_cache.py new file mode 100644 index 0000000..7009c05 --- /dev/null +++ b/services/data/app/cache/query_cache.py @@ -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() diff --git a/services/data/app/cache/schema_cache.py b/services/data/app/cache/schema_cache.py new file mode 100644 index 0000000..72513cd --- /dev/null +++ b/services/data/app/cache/schema_cache.py @@ -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() diff --git a/services/data/app/config.py b/services/data/app/config.py new file mode 100644 index 0000000..35d8600 --- /dev/null +++ b/services/data/app/config.py @@ -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() diff --git a/services/data/app/excel/__init__.py b/services/data/app/excel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/data/app/excel/header_detect.py b/services/data/app/excel/header_detect.py new file mode 100644 index 0000000..ef5a01d --- /dev/null +++ b/services/data/app/excel/header_detect.py @@ -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 diff --git a/services/data/app/excel/layout.py b/services/data/app/excel/layout.py new file mode 100644 index 0000000..03aef23 --- /dev/null +++ b/services/data/app/excel/layout.py @@ -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 diff --git a/services/data/app/excel/loader.py b/services/data/app/excel/loader.py new file mode 100644 index 0000000..3de813a --- /dev/null +++ b/services/data/app/excel/loader.py @@ -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) diff --git a/services/data/app/ingest/__init__.py b/services/data/app/ingest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/data/app/ingest/build_index.py b/services/data/app/ingest/build_index.py new file mode 100644 index 0000000..2accca0 --- /dev/null +++ b/services/data/app/ingest/build_index.py @@ -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() diff --git a/services/data/app/ingest/to_sql.py b/services/data/app/ingest/to_sql.py new file mode 100644 index 0000000..8d81b5f --- /dev/null +++ b/services/data/app/ingest/to_sql.py @@ -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() diff --git a/services/data/app/main.py b/services/data/app/main.py new file mode 100644 index 0000000..3c543c9 --- /dev/null +++ b/services/data/app/main.py @@ -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() diff --git a/services/data/app/models.py b/services/data/app/models.py new file mode 100644 index 0000000..781cb8b --- /dev/null +++ b/services/data/app/models.py @@ -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) diff --git a/services/data/app/providers/__init__.py b/services/data/app/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/data/app/providers/base.py b/services/data/app/providers/base.py new file mode 100644 index 0000000..9ce9c87 --- /dev/null +++ b/services/data/app/providers/base.py @@ -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 diff --git a/services/data/app/providers/excel_provider.py b/services/data/app/providers/excel_provider.py new file mode 100644 index 0000000..a136ea2 --- /dev/null +++ b/services/data/app/providers/excel_provider.py @@ -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) diff --git a/services/data/app/providers/factory.py b/services/data/app/providers/factory.py new file mode 100644 index 0000000..8190da8 --- /dev/null +++ b/services/data/app/providers/factory.py @@ -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) diff --git a/services/data/app/providers/sql_provider.py b/services/data/app/providers/sql_provider.py new file mode 100644 index 0000000..c002a47 --- /dev/null +++ b/services/data/app/providers/sql_provider.py @@ -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)}) diff --git a/services/data/data_files/.gitkeep b/services/data/data_files/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/data/requirements.txt b/services/data/requirements.txt new file mode 100644 index 0000000..7b91692 --- /dev/null +++ b/services/data/requirements.txt @@ -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 diff --git a/services/data/scripts/make_sample_data.py b/services/data/scripts/make_sample_data.py new file mode 100644 index 0000000..8428095 --- /dev/null +++ b/services/data/scripts/make_sample_data.py @@ -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}") diff --git a/services/logic/Dockerfile b/services/logic/Dockerfile new file mode 100644 index 0000000..a2d1821 --- /dev/null +++ b/services/logic/Dockerfile @@ -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"] diff --git a/services/logic/README.md b/services/logic/README.md new file mode 100644 index 0000000..08da50e --- /dev/null +++ b/services/logic/README.md @@ -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. diff --git a/services/logic/app/__init__.py b/services/logic/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/logic/app/clients/__init__.py b/services/logic/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/logic/app/clients/data_client.py b/services/logic/app/clients/data_client.py new file mode 100644 index 0000000..ecbd686 --- /dev/null +++ b/services/logic/app/clients/data_client.py @@ -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() diff --git a/services/logic/app/config.py b/services/logic/app/config.py new file mode 100644 index 0000000..04e2117 --- /dev/null +++ b/services/logic/app/config.py @@ -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() diff --git a/services/logic/app/main.py b/services/logic/app/main.py new file mode 100644 index 0000000..558531a --- /dev/null +++ b/services/logic/app/main.py @@ -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 diff --git a/services/logic/app/models.py b/services/logic/app/models.py new file mode 100644 index 0000000..d759508 --- /dev/null +++ b/services/logic/app/models.py @@ -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) diff --git a/services/logic/app/service.py b/services/logic/app/service.py new file mode 100644 index 0000000..4a1250f --- /dev/null +++ b/services/logic/app/service.py @@ -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"), + }, + ) diff --git a/services/logic/requirements.txt b/services/logic/requirements.txt new file mode 100644 index 0000000..4841cbd --- /dev/null +++ b/services/logic/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115 +uvicorn[standard]>=0.34 +httpx>=0.28 +pydantic>=2.10 diff --git a/services/presentation/Dockerfile b/services/presentation/Dockerfile new file mode 100644 index 0000000..c7df951 --- /dev/null +++ b/services/presentation/Dockerfile @@ -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"] diff --git a/services/presentation/README.md b/services/presentation/README.md new file mode 100644 index 0000000..7f64cfc --- /dev/null +++ b/services/presentation/README.md @@ -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 +``` diff --git a/services/presentation/app/__init__.py b/services/presentation/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/presentation/app/clients/__init__.py b/services/presentation/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py new file mode 100644 index 0000000..665f055 --- /dev/null +++ b/services/presentation/app/clients/logic_client.py @@ -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() diff --git a/services/presentation/app/config.py b/services/presentation/app/config.py new file mode 100644 index 0000000..753cdc2 --- /dev/null +++ b/services/presentation/app/config.py @@ -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() diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py new file mode 100644 index 0000000..edc405b --- /dev/null +++ b/services/presentation/app/main.py @@ -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"} diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css new file mode 100644 index 0000000..2b67cfb --- /dev/null +++ b/services/presentation/app/static/styles.css @@ -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; } diff --git a/services/presentation/app/templates/index.html b/services/presentation/app/templates/index.html new file mode 100644 index 0000000..aa40966 --- /dev/null +++ b/services/presentation/app/templates/index.html @@ -0,0 +1,62 @@ + + + + + + astrololo + + + +
+

astrololo

+

Warstwa prezentacji → logiczna → bazodanowa

+ +
+
+ + + +
+
+ + +
+
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if result %} +
+ Znaleziono {{ result.count }} · + provider: {{ result.meta.data_provider }} · + cache: {{ result.meta.data_cache }} · + {{ result.meta.data_elapsed_ms }} ms +
+ {% if result.results %} + + + {% for col in result.results[0].keys() %}{% endfor %} + + + {% for row in result.results %} + {% for v in row.values() %}{% endfor %} + {% endfor %} + +
{{ col }}
{{ v }}
+ {% else %} +

Brak wyników.

+ {% endif %} + {% endif %} +
+ + diff --git a/services/presentation/requirements.txt b/services/presentation/requirements.txt new file mode 100644 index 0000000..71982e7 --- /dev/null +++ b/services/presentation/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.115 +uvicorn[standard]>=0.34 +httpx>=0.28 +jinja2>=3.1 +python-multipart>=0.0.20