"""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.clients.data_client import DataClient from app.models import QueryRequest, QueryResponse from app.service import QueryService app = FastAPI(title="astrololo · warstwa logiczna") service = QueryService() # --- 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) @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) chart = build_chart(engine, moment, req.house_system) 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 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 @app.post("/chart/timeline") def chart_timeline(req: TimelineRequest) -> dict: """Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.""" 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) return {"engine": engine.name, "from": req.from_date, "to": req.to_date, "count": len(events), "events": events} @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