4d1daab35a
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 18s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
build / build (push) Successful in 26s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m37s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m27s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 10s
Ostatnie trzy pozycje z treści LOG-05, których wcześniej nie było: - whole_sign_aries — znaki jako domy, ale dom I ZAWSZE na 0° Barana, niezależnie od Ascendentu (tradycja indyjska i część szkół hellenistycznych), - equal_mc — równe domy zakotwiczone na MC: dom X zaczyna się dokładnie na południku, a nie ma go gdzieś w środku, - obrót kosmogramu: Ascendent albo 0° Barana po lewej stronie koła. Zmienia WYŁĄCZNIE rysunek, żadna liczba nie jest przeliczana. Wariant „od Barana" unieruchamia koło względem zodiaku, więc dwa horoskopy da się porównać na oko. Oba nowe systemy zgodne z wyrocznią co do zera od pierwszego uruchomienia. Razem 13 systemów: 2 833 424 porównania w przemiale, zero przekroczeń. PRZY OKAZJI — DWA BŁĘDY, KTÓRE SAM WPROWADZIŁEM I KTÓRYCH TESTY NIE WIDZIAŁY: - linia zbierająca ostrzeżenia o fallbacku trafiła do handlera strony głównej zamiast do compile_pdf: odwoływała się do nieistniejącej zmiennej, czyli 500 na stronie głównej, a do PDF-a ostrzeżenia nie docierały wcale, - compile_build używał parametru formularza, którego nie miał w sygnaturze. Oba przeszły przez komplet zielonych testów, bo żaden nie wywoływał POST-a — testy prezentacji sprawdzały teksty w szablonach i w main.py. Doszły więc testy uderzające w prawdziwe trasy (POST / i POST /compile ze stubowaną logiką), które łapią tę klasę błędu. Z tego samego powodu przepisane dwa testy PDF-a: greppowały z main.py dokładny kształt wywołania render(chart, theme="print") i pękały przy dopisaniu argumentu, mimo poprawnego zachowania. Teraz wołają trasę i sprawdzają, że KAŻDY z czterech rysunków dostaje motyw druku. LOG-05 i PRE-05 → Zrobione w docs/astrololo_wymagania.xlsx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
614 lines
27 KiB
Python
614 lines
27 KiB
Python
"""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 import chartwheel as chartwheel_mod
|
||
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
|
||
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
||
|
||
|
||
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),
|
||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
|
||
):
|
||
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,
|
||
"wheel_orientation": wheel_orientation}
|
||
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 # kosmogram (PRE-12), SVG po stronie serwera
|
||
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
|
||
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
|
||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
|
||
):
|
||
"""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,
|
||
"wheel_orientation": wheel_orientation}
|
||
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"], orientation=wheel_orientation)
|
||
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"),
|
||
)
|
||
# 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
|
||
|
||
# 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",
|
||
orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
|
||
"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"}
|