bfa2d1f9d3
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m50s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m53s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 28s
Testy / Kontrola składni wszystkich warstw (push) Successful in 24s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m45s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m56s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 24s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 24s
Bazy sa rdzeniem produktu i wlasnie zostaly kupione — a aplikacja nie miala ZADNEGO uwierzytelniania. Prezentacja to NodePort, wiec kazdy w LAN wchodzil bez logowania, a `/search` oddawal surowe wiersze do 50 000 na zapytanie. Bazy mogly wyjsc przez sama aplikacje, bez udzialu jakiegokolwiek LLM. - prezentacja: HTTP Basic (APP_USER/APP_PASSWORD) + limit zadan na IP (RATE_LIMIT_PER_MIN, domyslnie 120/min). Limit dziala TAKZE przed uwierzytelnieniem, zeby zgadywanie hasla i sondowanie API nie bylo darmowe. - logika i dane: token miedzywarstwowy X-Astrololo-Token (INTERNAL_TOKEN) — bez niego dalo sie ominac logowanie, uderzajac wprost w warstwe nizej. Warstwa danych oddaje surowe wiersze, wiec to najwrazliwszy punkt. - /search: gorny limit 50 000 -> 5000 (tyle realnie uzywa build_report). Publiczne /api/query zostaje na 200. - /health celowo publiczny (sondy k8s go nie uwierzytelnia). - swiadomie nie logujemy tresci zadan ani promptow — logi to kolejny nosnik. Fail-open przy braku konfiguracji (zgodnosc wstecz i dev), ale z GLOSNYM ostrzezeniem przy starcie, zeby nikt nie wdrozyl tego w przekonaniu, ze jest chroniony. Wlaczenie w produkcji wymaga ustawienia sekretow w repo deploy. Testy: 12 (prezentacja, nowy katalog + job w CI) i 5 (logika). Zweryfikowane na zywym stosie: bez hasla 401, z haslem 200, logika wprost bez tokenu 401, z tokenem 200, /health 200, limit 50000 odrzucony (422), a prezentacja nadal liczy horoskop przez logike. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
9.0 KiB
Python
250 lines
9.0 KiB
Python
"""Warstwa LOGICZNA — usługa HTTP.
|
|
|
|
W górę: udostępnia API dla warstwy prezentacji.
|
|
W dół: woła warstwę bazodanową (DataClient).
|
|
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from app import security
|
|
from app.clients.data_client import DataClient
|
|
from app.models import QueryRequest, QueryResponse
|
|
from app.service import QueryService
|
|
|
|
app = FastAPI(title="astrololo · warstwa logiczna")
|
|
service = QueryService()
|
|
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
|
|
|
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
|
_engine = None
|
|
|
|
|
|
def get_engine():
|
|
global _engine
|
|
if _engine is None:
|
|
from app.engine.factory import build_engine
|
|
|
|
_engine = build_engine()
|
|
return _engine
|
|
|
|
|
|
class PositionsRequest(BaseModel):
|
|
when_utc: datetime # moment w UTC (świadomy strefy)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
objects: list[str] | None = None
|
|
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
|
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
|
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
|
|
|
|
|
@app.post("/api/query", response_model=QueryResponse)
|
|
def query(req: QueryRequest) -> QueryResponse:
|
|
try:
|
|
return service.handle(req)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
|
|
|
|
|
@app.post("/chart/positions")
|
|
def chart_positions(req: PositionsRequest) -> dict:
|
|
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
|
|
opcjonalnie stacje planet (LOG-03, stations=true)."""
|
|
from app.engine.chart import build_chart
|
|
from app.engine.models import ChartMoment
|
|
|
|
engine = get_engine()
|
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
try:
|
|
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
if req.stations:
|
|
from app.engine.stations import find_stations
|
|
|
|
for p in chart["positions"]:
|
|
st = find_stations(engine, moment, p["name"])
|
|
if st:
|
|
p["stations"] = st
|
|
return chart
|
|
|
|
|
|
@app.post("/chart/compare")
|
|
def chart_compare(req: PositionsRequest) -> dict:
|
|
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
|
|
|
|
Wymaga skonfigurowanego ENGINE_SWISSEPH_URL (silnik B). W przeciwnym razie
|
|
zwraca informację, że porównanie jest niedostępne.
|
|
"""
|
|
from app.engine.compare import compare_engines
|
|
from app.engine.factory import build_engine
|
|
from app.engine.models import ChartMoment
|
|
|
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
try:
|
|
report = compare_engines(build_engine("own"), build_engine("swisseph"), moment)
|
|
except (RuntimeError, httpx.HTTPError) as e:
|
|
raise HTTPException(status_code=503, detail=f"Silnik B niedostępny: {e}")
|
|
return report.summary()
|
|
|
|
|
|
class ReportRequest(BaseModel):
|
|
when_utc: datetime
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
limit: int = 5000
|
|
group: bool = False # grupowanie identycznych opisów
|
|
|
|
|
|
@app.post("/chart/report")
|
|
def chart_report(req: ReportRequest) -> dict:
|
|
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
|
|
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
|
|
from app.engine.chart import build_chart
|
|
from app.engine.models import ChartMoment
|
|
from app.significators import build_report
|
|
|
|
engine = get_engine()
|
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
chart = build_chart(engine, moment) # pozycje z domami + aspekty
|
|
try:
|
|
report = build_report(
|
|
chart["positions"], DataClient(),
|
|
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
|
return {"engine": engine.name, **report}
|
|
|
|
|
|
class ProfectionsRequest(BaseModel):
|
|
when_utc: datetime # moment urodzenia (UTC)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
start_age: int = 0
|
|
count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy
|
|
|
|
|
|
@app.post("/chart/profections")
|
|
def chart_profections(req: ProfectionsRequest) -> dict:
|
|
"""Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo)."""
|
|
from app.engine import houses as H
|
|
from app.engine.models import ChartMoment
|
|
from app.engine.profections import profection_rows
|
|
|
|
engine = get_engine()
|
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
ramc, eps = engine.sidereal(natal)
|
|
points = {
|
|
"Asc": H.compute_asc(ramc, eps, natal.lat),
|
|
"MC": H.compute_mc(ramc, eps),
|
|
}
|
|
for p in engine.positions(natal, ["Sun", "Moon"]):
|
|
points[p.name] = p.longitude
|
|
rows = profection_rows(points, req.when_utc, req.start_age, req.count)
|
|
return {"engine": engine.name, "rows": rows}
|
|
|
|
|
|
class ReturnRequest(BaseModel):
|
|
when_utc: datetime # moment urodzenia (UTC)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
kind: str = "solar" # solar | lunar
|
|
around: datetime | None = None # data, wokół której szukać powrotu
|
|
|
|
|
|
@app.post("/chart/return")
|
|
def chart_return(req: ReturnRequest) -> dict:
|
|
"""Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment."""
|
|
from app.engine.chart import build_chart
|
|
from app.engine.models import ChartMoment
|
|
from app.engine.returns import find_return
|
|
|
|
if req.kind not in ("solar", "lunar"):
|
|
raise HTTPException(status_code=422, detail="kind: solar albo lunar")
|
|
engine = get_engine()
|
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
around = req.around or req.when_utc
|
|
hit = find_return(engine, req.kind, natal, around)
|
|
if hit is None:
|
|
raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu")
|
|
chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon))
|
|
return {"engine": engine.name, "kind": req.kind,
|
|
"return_utc": hit.isoformat(), **chart}
|
|
|
|
|
|
class FirdariaRequest(BaseModel):
|
|
when_utc: datetime # moment urodzenia (UTC)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
|
|
|
|
@app.post("/chart/firdaria")
|
|
def chart_firdaria(req: FirdariaRequest) -> dict:
|
|
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
|
|
from app.engine import houses as H
|
|
from app.engine.firdaria import firdaria
|
|
from app.engine.models import ChartMoment
|
|
|
|
engine = get_engine()
|
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
ramc, eps = engine.sidereal(natal)
|
|
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
|
|
sun = engine.positions(natal, ["Sun"])[0].longitude
|
|
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
|
|
|
|
|
|
class TimelineRequest(BaseModel):
|
|
when_utc: datetime # moment urodzenia (UTC)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
from_date: str # zakres: YYYY-MM-DD
|
|
to_date: str
|
|
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
|
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
|
|
|
|
|
|
@app.post("/chart/timeline")
|
|
def chart_timeline(req: TimelineRequest) -> dict:
|
|
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
|
|
|
|
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
|
|
"""
|
|
from app.engine import houses as H
|
|
from app.engine.models import ChartMoment
|
|
from app.engine.timeline import build_timeline
|
|
|
|
engine = get_engine()
|
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
ramc, eps = engine.sidereal(natal)
|
|
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
|
|
for p in engine.positions(natal):
|
|
points[p.name] = p.longitude
|
|
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
|
|
|
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
|
|
if req.interpret:
|
|
from app.significators import interpret_events
|
|
try:
|
|
interpret_events(events, DataClient())
|
|
except httpx.HTTPError as e:
|
|
out["data_error"] = f"Warstwa danych niedostępna: {e}"
|
|
out.update(count=len(events), events=events)
|
|
return out
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
info = {"status": "ok", "layer": "logic"}
|
|
try:
|
|
info["data_layer"] = DataClient().health()
|
|
except httpx.HTTPError as e:
|
|
info["data_layer"] = {"status": "down", "error": str(e)}
|
|
return info
|