"""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 hashlib import json from datetime import datetime, timedelta, timezone from functools import lru_cache from pathlib import Path 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.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label 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) # ── cache-busting statyki (PRE-26) ───────────────────────────────────────── # Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache). # Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL → # przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej). # Hash liczony raz na proces (lru_cache) — nowy pod po deployu = świeży hash. _STATIC_DIR = Path("app/static") @lru_cache(maxsize=None) def _asset_version(name: str) -> str: try: return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8] except OSError: return "0" # brak pliku nie może wywrócić strony def static_url(name: str) -> str: return f"/static/{name}?v={_asset_version(name)}" templates.env.globals["static"] = static_url # Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów. templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS templates.env.globals["house_label"] = house_label templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED 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 _report_records(report: dict | None) -> int: """Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17). Liczymy próbki we wszystkich fasetach: to one są treścią z baz. Sama liczba, nigdy treść — dziennik ma pokazywać SKALĘ dostępu, nie powielać danych.""" total = 0 for o in (report or {}).get("objects") or []: for f in o.get("facets") or []: total += len(f.get("samples") or []) return total 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 aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów aspect_luminary_bonus: float = Form(2.0), aspect_minor: bool = Form(False), 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, "aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor, "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, aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor, stations=stations, zodiac=zodiac, tables=tables, ) # Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu # nie da się poznać, że podział jest z innego systemu, niż zamówiono. warnings = [w for w in (chart.get("house_warnings") or []) if w] 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"), house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów aspect_luminary_bonus: float = Form(2.0), aspect_minor: bool = Form(False), stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu zodiac: str = Form("tropical"), tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu ): """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). Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które wybrano przy horoskopie (stacje, tabele, porównanie domów) — inaczej podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między zakładkami przez formsync. """ form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "house_system": house_system, "house_systems": house_systems, "aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor, "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, aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor, stations=stations, zodiac=zodiac, tables=tables, ) 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] = [] warnings: list[str] = [] 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"), house_systems=[s for s in (data.get("house_systems") or []) if s], aspect_orb=float(data.get("aspect_orb") or 8.0), aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0), aspect_minor=bool(data.get("aspect_minor")), stations=bool(data.get("stations")), tables=bool(data.get("tables")), 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, "warnings": warnings, "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"'}) # ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ---------------- @app.get("/settings", response_class=HTMLResponse) def settings_view(request: Request): """Podgląd baz dostępnych na udziale + które biorą udział w interpretacji. Same metadane (nazwa, rozmiar, data) — bez treści baz. Przełączniki są DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu: udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po restarcie, po cichu włączając z powrotem wyłączoną bazę.""" ctx: dict = {"result": None, "error": None} try: ctx["result"] = logic.bases() except httpx.HTTPError as e: ctx["error"] = _logic_error(e) return templates.TemplateResponse(request, "settings.html", ctx) # ---------------- Synastria (technika relacyjna) ---------------- @app.get("/synastry", response_class=HTMLResponse) def synastry_form(request: Request): # domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce) form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0, "aspect_orb": 8, "aspect_luminary_bonus": 2} return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form}) @app.post("/synastry", response_class=HTMLResponse) def synastry_run( request: Request, a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...), a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0), b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...), b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0), zodiac: str = Form("tropical"), aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0), aspect_minor: bool = Form(False), ): """Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami.""" form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset, "a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date, "b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon, "zodiac": zodiac, "aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor} ctx: dict = {"form": form, "result": None, "error": None} try: iso_a, _ = _build_utc(a_date, a_time, a_tz_offset) iso_b, _ = _build_utc(b_date, b_time, b_tz_offset) ctx["result"] = logic.synastry( {"when_utc": iso_a, "lat": a_lat, "lon": a_lon}, {"when_utc": iso_b, "lat": b_lat, "lon": b_lon}, zodiac=zodiac, aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor, ) 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, "synastry.html", ctx) # ---------------- 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) # ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści security.audit_records(request, len((ctx["result"] or {}).get("rows") or [])) 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 == "export": # Tabela robocza do Excela (DAN-23/PRE-10) — pobranie pliku, nie strona. from fastapi.responses import Response from app.report_export import report_to_xlsx report = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group) # Eksport wynosi NAJWIĘCEJ treści baz naraz — tym bardziej ma zostawiać # ślad w dzienniku (PRE-17): sama liczba rekordów, nigdy treść. security.audit_records(request, _report_records(report)) return Response( content=report_to_xlsx(report), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'}, ) 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) security.audit_records(request, _report_records(ctx["result"])) 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"}