"""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. Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji (do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod „/significators". """ # build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest from __future__ import annotations import json from datetime import datetime, timedelta, timezone import httpx from fastapi import FastAPI, Form, HTTPException, Query, Request from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from app import geocode, security from app.clients.logic_client import LogicClient from app.config import DEFAULT_LOCATION_LABEL, default_form app = FastAPI(title="astrololo · warstwa prezentacji") app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") logic = LogicClient() security.install(app) # logowanie + limit żądań (LOG-32) def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: """Z lokalnej daty/godziny + przesunięcia strefy → moment UTC. Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny − offset. """ local = datetime.fromisoformat(f"{date}T{time}") utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc) label = utc.strftime("%Y-%m-%d %H:%M UTC") return utc.isoformat(), label def _llm_catalog() -> dict: """Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony — pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie.""" try: return logic.llm_models() except httpx.HTTPError: return {"providers": {}, "defaults": {}} def _logic_error(e: Exception) -> str: if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404: return ( "Warstwa logiczna działa, ale nie ma endpointu /chart/positions. " "Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)." ) return f"Warstwa logiczna niedostępna: {e}" # ---------------- Horoskop: pozycje (strona główna) ---------------- @app.get("/", response_class=HTMLResponse) def chart_form(request: Request): return templates.TemplateResponse( request, "chart.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, "llm_catalog": _llm_catalog()}, ) @app.post("/", response_class=HTMLResponse) def chart_compute( request: Request, person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24) date: str = Form(...), time: str = Form(...), tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0), house_system: str = Form("whole_sign"), house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania stations: bool = Form(False), zodiac: str = Form("tropical"), tables: bool = Form(False), ): form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "house_system": house_system, "house_systems": house_systems, "stations": stations, "zodiac": zodiac, "tables": tables} ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label ctx["result"] = logic.positions( when_utc_iso=iso_utc, lat=lat, lon=lon, house_system=house_system, house_systems=house_systems, stations=stations, zodiac=zodiac, tables=tables, ) from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera ctx["wheel_svg"] = chartwheel.render(ctx["result"]) ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) except ValueError as e: ctx["error"] = f"Niepoprawne dane wejściowe: {e}" return templates.TemplateResponse(request, "chart.html", ctx) # ---------------- Skompiluj: zbiorczy raport (PRE-23) ---------------- @app.get("/compile", response_class=HTMLResponse) def compile_form(request: Request): return templates.TemplateResponse( request, "compile.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL}, ) @app.post("/compile", response_class=HTMLResponse) def compile_build( request: Request, person: str = Form(""), date: str = Form(...), time: str = Form(...), tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0), house_system: str = Form("whole_sign"), zodiac: str = Form("tropical"), ): """Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23). Dlaczego horoskop liczymy ponownie, zamiast go zapamiętywać: to czysta funkcja danych wejściowych — tanio ją powtórzyć, a odpada trzymanie w przeglądarce dużego wyniku, który mógłby się rozjechać z aktualnym formularzem. """ form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "house_system": house_system, "zodiac": zodiac} ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label ctx["result"] = logic.positions( when_utc_iso=iso_utc, lat=lat, lon=lon, house_system=house_system, zodiac=zodiac, ) from app import chartwheel ctx["wheel_svg"] = chartwheel.render(ctx["result"]) ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) except ValueError as e: ctx["error"] = f"Niepoprawne dane wejściowe: {e}" return templates.TemplateResponse(request, "compile.html", ctx) @app.post("/compile/pdf") def compile_pdf(payload: dict): """Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu. Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje) mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF zawierał dokładnie to, co widać na stronie. Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku. """ from fastapi.responses import Response from app.clients.render_client import RenderClient data = payload.get("data") or {} try: iso_utc, label = _build_utc( str(data.get("date") or ""), str(data.get("time") or ""), float(data.get("tz_offset") or 0.0), ) except (ValueError, TypeError) as e: return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422) figures: list[dict] = [] try: chart = logic.positions( when_utc_iso=iso_utc, lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0), house_system=str(data.get("house_system") or "whole_sign"), zodiac=str(data.get("zodiac") or "tropical"), ) from app import chartwheel # Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki # w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc # zmienne CSS i font glifów muszą być wprost w rysunku. for svg, caption in ( (chartwheel.render(chart, theme="print"), "Kosmogram"), (chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"), (chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"), (chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"), ): if svg: figures.append({"svg": svg, "caption": caption}) except httpx.HTTPError as e: # Brak rysunków nie może zablokować raportu — tekst jest ważniejszy. data = {**data, "wheel_error": _logic_error(e)} report = { "person": payload.get("person") or "", "data": {**data, "moment_utc": label}, "figures": figures, "natal": payload.get("natal") or {}, "predictions": payload.get("predictions") or [], } try: pdf = RenderClient().pdf(report) except httpx.HTTPError as e: return JSONResponse( {"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"}, status_code=502, ) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": 'attachment; filename="raport.pdf"'}) # ---------------- Sygnifikatory (wyszukiwarka w bazach) ---------------- @app.get("/significators", response_class=HTMLResponse) def significators_form(request: Request): return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}}) @app.post("/significators", response_class=HTMLResponse) def significators_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"] = _logic_error(e) return templates.TemplateResponse(request, "significators.html", ctx) # ---------------- Interpretacje (wynik obliczeń szukany w bazie) ---------------- @app.get("/interpret", response_class=HTMLResponse) def interpret_form(request: Request): return templates.TemplateResponse( request, "interpret.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, "llm_catalog": _llm_catalog()}, ) @app.post("/interpret", response_class=HTMLResponse) def interpret_run( request: Request, person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24) date: str = Form(...), time: str = Form(...), tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0), group: bool = Form(False), action: str = Form("report"), prompt_budget: str = Form("medium"), llm_provider: str = Form("local"), llm_model: str = Form(""), ): form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget, "llm_provider": llm_provider, "llm_model": llm_model} ctx: dict = {"form": form, "result": None, "error": None, "moment": None, "llm_catalog": _llm_catalog()} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label if action == "prompt": ctx["prompt_result"] = logic.prompt( profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, provider=llm_provider, model=llm_model, ) elif action == "horoscope": ctx["prompt_result"] = logic.horoscope( profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, provider=llm_provider, model=llm_model, ) else: ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group) except httpx.HTTPError as e: ctx["error"] = _logic_error(e) except ValueError as e: ctx["error"] = f"Niepoprawne dane wejściowe: {e}" return templates.TemplateResponse(request, "interpret.html", ctx) # ---------------- Kalendarz (oś czasu z technik + interpretacje) ---------------- @app.get("/timeline", response_class=HTMLResponse) def timeline_form(request: Request): return templates.TemplateResponse( request, "timeline.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, "llm_catalog": _llm_catalog()}, ) @app.post("/timeline", response_class=HTMLResponse) def timeline_run( request: Request, person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24) date: str = Form(...), time: str = Form(...), tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0), from_date: str = Form(...), to_date: str = Form(...), action: str = Form("timeline"), prompt_budget: str = Form("medium"), llm_provider: str = Form("local"), llm_model: str = Form(""), ): form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget, "llm_provider": llm_provider, "llm_model": llm_model} ctx: dict = {"form": form, "result": None, "error": None, "moment": None, "llm_catalog": _llm_catalog()} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label if action == "prompt": ctx["prompt_result"] = logic.prompt( profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, from_date=from_date, to_date=to_date, provider=llm_provider, model=llm_model, ) elif action == "horoscope": ctx["prompt_result"] = logic.horoscope( profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, provider=llm_provider, model=llm_model, from_date=from_date, to_date=to_date, ) else: ctx["result"] = logic.timeline( when_utc_iso=iso_utc, lat=lat, lon=lon, from_date=from_date, to_date=to_date, interpret=True, ) except httpx.HTTPError as e: ctx["error"] = _logic_error(e) except ValueError as e: ctx["error"] = f"Niepoprawne dane wejściowe: {e}" return templates.TemplateResponse(request, "timeline.html", ctx) # ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ---------------- @app.post("/horoscope/stream") def horoscope_stream( profile: str = Form("natal"), date: str = Form(...), time: str = Form(...), tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0), prompt_budget: str = Form("medium"), llm_provider: str = Form("local"), llm_model: str = Form(""), from_date: str = Form(""), to_date: str = Form(""), ): """Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku. Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku. """ from fastapi.responses import StreamingResponse try: iso_utc, _ = _build_utc(date, time, tz_offset) except ValueError as e: return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422) payload: dict = { "profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon, "budget": prompt_budget, "provider": llm_provider, "model": llm_model, } if profile == "period" and from_date and to_date: payload["from_date"], payload["to_date"] = from_date, to_date def relay(): try: for raw in logic.horoscope_stream(payload): try: event = json.loads(raw) except ValueError: continue if event.get("type") == "result": html = templates.get_template("_prompt_result.html").render( prompt_result=event.get("result") or {} ) event["html"] = html yield json.dumps(event, ensure_ascii=False) + "\n" except httpx.HTTPError as e: yield json.dumps({"type": "error", "message": _logic_error(e)}, ensure_ascii=False) + "\n" return StreamingResponse(relay(), media_type="application/x-ndjson", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}) # ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ---------------- @app.get("/geocode") def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")): """Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca").""" try: return {"results": geocode.search(q)} except httpx.HTTPError as e: raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}") @app.get("/reverse") def geocode_reverse(lat: float, lon: float): """Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu).""" try: return geocode.reverse(lat, lon) except httpx.HTTPError as e: raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}") @app.get("/timezone") def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"): """Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03). Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404, gdy strefy nie da się ustalić — front zostawia wtedy ręczny offset.""" from app import timezone as tzmod res = tzmod.resolve(lat, lon, date, time) if not res: raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.") return res @app.get("/health") def health() -> dict: return {"status": "ok", "layer": "presentation"}