Files
astrololo/services/presentation/app/main.py
T
gitea 9b1e4dbb20
build / build (push) Successful in 1m3s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m7s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
feat: wiele systemów domów naraz — porównanie obok siebie (PRE-05/LOG-05, A2a)
Astrolodzy spierają się o systemy domów; teraz można policzyć kilka na raz i
zobaczyć, jak różny podział przesuwa planety między domami.

Domy to geometria z Asc/MC — osie są WSPÓLNE, różni się tylko podział. Prymarny
system zostaje w `cusps`/`house_system`/`positions[].house` (pod kosmogram i
wstecznie — nic się nie zmienia dla dotychczasowych ścieżek). Nowość:
- logika: `build_chart(..., house_systems=[...])` → `result["house_systems"]` =
  pełen zestaw (prymarny pierwszy, bez duplikatów), a `positions[].houses[system]`
  mówi, w którym domu obiekt siedzi wg każdego systemu. Doklejane tylko gdy > 1.
  Nieznany system (np. placidus — dojdzie przez swisseph osobno, A2b) pomijany,
  nie wywala horoskopu.
- endpoint `/chart/positions`: pole `house_systems`; klient prezentacji przekazuje.
- UI: checkboxy „Porównaj systemy domów" (whole sign / equal / porphyry) + tabela
  kusp obok siebie (12 domów × systemy), stan zaznaczeń przeżywa submit.

Na razie 3 systemy z czystej matmy (`houses.py`) — zero zależności, zero walidacji
krzyżowej. Egzotyczne (Placidus/Koch/Regiomontanus/Campanus) dojdą przez endpoint
`/houses` w silniku B (swisseph) jako A2b — maszyneria „naraz" jest już gotowa,
egzotyczne tylko dopiszą kolejne wpisy.

Weryfikacja: żywy /chart/positions — prymarny whole_sign, house_systems
[whole_sign, equal, porphyry], 12 kusp/system, dom per system per obiekt. Testy:
logika +5, prezentacja +6. Logika 270, prezentacja 179.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 00:03:47 +00:00

453 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"}