mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 21:38:37 +00:00
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>
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
"""Odcisk pliku = klucz unieważniania cache.
|
||||
|
||||
Wszystkie warstwy cache są kluczowane odciskiem pliku. Gdy plik Excela się zmieni,
|
||||
zmienia się odcisk -> automatyczny "cache miss" i przebudowa. Domyślnie używamy
|
||||
taniego (mtime + rozmiar); sha256 dostępne, gdy potrzeba pewności co do treści.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
|
||||
def fingerprint(path: str, strong: bool = False) -> str:
|
||||
if strong:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return "sha256:" + h.hexdigest()[:16]
|
||||
|
||||
st = os.stat(path)
|
||||
return f"mt:{int(st.st_mtime)}:{st.st_size}"
|
||||
+35
@@ -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)
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
"""Odwrócony indeks: wartość kanonicznego klucza -> które pliki/arkusze ją mają.
|
||||
|
||||
POZIOM 4 (i najważniejszy przy skali) — pozwala NIE skanować setek plików przy
|
||||
każdym zapytaniu. Budujemy w SQLite indeks: dla wybranych kluczy (np. name, id,
|
||||
symbol) zapisujemy, w którym pliku/arkuszu występuje dana wartość. Wyszukiwanie
|
||||
najpierw pyta indeks (jeden szybki SELECT), a otwiera tylko trafione pliki.
|
||||
|
||||
Ten SQLite indeks jest jednocześnie POMOSTEM do pełnej migracji SQL — rozbudowa
|
||||
go o wszystkie kolumny = de facto baza danych (patrz ingest/to_sql.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class InvertedIndex:
|
||||
def __init__(self, cache_dir: Path) -> None:
|
||||
self._db = sqlite3.connect(str(cache_dir / "index.db"), check_same_thread=False)
|
||||
self._db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
key TEXT, -- kanoniczne pole, np. 'name'
|
||||
value TEXT, -- znormalizowana (lower) wartość
|
||||
file TEXT, -- ścieżka pliku
|
||||
sheet TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._db.execute("CREATE INDEX IF NOT EXISTS ix_kv ON entries(key, value)")
|
||||
self._db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS files (file TEXT PRIMARY KEY, fingerprint TEXT)"
|
||||
)
|
||||
self._db.commit()
|
||||
|
||||
def file_fingerprint(self, file: str) -> str | None:
|
||||
cur = self._db.execute("SELECT fingerprint FROM files WHERE file=?", (file,))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def reindex_file(self, file: str, fingerprint: str, rows: list[tuple[str, str, str]]) -> None:
|
||||
"""rows: lista (key, value, sheet) dla jednego pliku."""
|
||||
self._db.execute("DELETE FROM entries WHERE file=?", (file,))
|
||||
self._db.executemany(
|
||||
"INSERT INTO entries(key, value, file, sheet) VALUES (?,?,?,?)",
|
||||
[(k, v.lower(), file, sheet) for (k, v, sheet) in rows],
|
||||
)
|
||||
self._db.execute(
|
||||
"INSERT OR REPLACE INTO files(file, fingerprint) VALUES (?,?)", (file, fingerprint)
|
||||
)
|
||||
self._db.commit()
|
||||
|
||||
def lookup(self, key: str, value: str, exact: bool) -> list[tuple[str, str]]:
|
||||
"""Zwraca listę (file, sheet) kandydatów do przeszukania."""
|
||||
if exact:
|
||||
cur = self._db.execute(
|
||||
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value=?",
|
||||
(key, value.lower()),
|
||||
)
|
||||
else:
|
||||
cur = self._db.execute(
|
||||
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value LIKE ?",
|
||||
(key, f"%{value.lower()}%"),
|
||||
)
|
||||
return [(r[0], r[1]) for r in cur.fetchall()]
|
||||
|
||||
def count_files(self) -> int:
|
||||
return self._db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
"""Cache wyników zapytań: in-memory LRU + TTL.
|
||||
|
||||
POZIOM 3 cache. Te same zapytania powtarzają się (popularne wyszukiwania). Tu
|
||||
trzymamy gotowy wynik przez krótki TTL. Bez zewnętrznych zależności — w produkcji
|
||||
można podmienić na Redis (ten sam interfejs get/put), by współdzielić cache
|
||||
między instancjami.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
|
||||
class QueryCache:
|
||||
def __init__(self, max_size: int = 512, ttl: int = 300) -> None:
|
||||
self.max_size = max_size
|
||||
self.ttl = ttl
|
||||
self._store: OrderedDict[str, tuple[float, Any]] = OrderedDict()
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
item = self._store.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
ts, value = item
|
||||
if time.time() - ts > self.ttl:
|
||||
del self._store[key]
|
||||
return None
|
||||
self._store.move_to_end(key)
|
||||
return value
|
||||
|
||||
def put(self, key: str, value: Any) -> None:
|
||||
self._store[key] = (time.time(), value)
|
||||
self._store.move_to_end(key)
|
||||
while len(self._store) > self.max_size:
|
||||
self._store.popitem(last=False)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._store.clear()
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""Cache schematu: wykryty wiersz nagłówka + mapowanie kolumn, per (plik, arkusz).
|
||||
|
||||
POZIOM 1 cache. Najdroższe jest samo wykrywanie nagłówka i wnioskowanie układu
|
||||
kolumn. Robimy to RAZ na wersję pliku i zapisujemy w SQLite. Kolejne odczyty
|
||||
pomijają skanowanie.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SchemaCache:
|
||||
def __init__(self, cache_dir: Path) -> None:
|
||||
self._db = sqlite3.connect(str(cache_dir / "schema.db"), check_same_thread=False)
|
||||
self._db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_cache (
|
||||
fingerprint TEXT,
|
||||
sheet TEXT,
|
||||
header_row INTEGER,
|
||||
mapping TEXT,
|
||||
PRIMARY KEY (fingerprint, sheet)
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._db.commit()
|
||||
|
||||
def get(self, fp: str, sheet: str) -> tuple[int, dict[str, str]] | None:
|
||||
cur = self._db.execute(
|
||||
"SELECT header_row, mapping FROM schema_cache WHERE fingerprint=? AND sheet=?",
|
||||
(fp, sheet),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return int(row[0]), json.loads(row[1])
|
||||
|
||||
def put(self, fp: str, sheet: str, header_row: int, mapping: dict[str, str]) -> None:
|
||||
self._db.execute(
|
||||
"INSERT OR REPLACE INTO schema_cache VALUES (?,?,?,?)",
|
||||
(fp, sheet, header_row, json.dumps(mapping, ensure_ascii=False)),
|
||||
)
|
||||
self._db.commit()
|
||||
Reference in New Issue
Block a user