f24616d342
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m57s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 34s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
Ostatnia z osmiu wskazowek partnerow. Nowa usluga services/render sklada raport:
generuje plik posredni .tex i kompiluje go XeLaTeX-em do PDF.
OSOBNY komponent, nie czesc prezentacji — ta sama zasada co przy izolacji
swissepha (LOG-27). TeX Live wazy setki megabajtow; w obrazie produktu
spowalnialby kazdy build, a tak aktualizuje sie niezaleznie i jego awaria nie
kladzie aplikacji, tylko przycisk „Pobierz PDF".
SZYFROWANIE, o ktore prosiles: lacze prezentacja↔render idzie tak samo jak
pozostale, ale z WLASNYM, TRZECIM kluczem (LINK_KEY_PRESENTATION_RENDER). Osobny,
bo tym laczem plynie CALY raport — dane urodzeniowe i opisy z baz — wiec przejecie
go nie moze otwierac lacza do logiki ani do danych. Fail-closed: bez klucza pod
nie wstaje. Test kopii link_crypto obejmuje teraz cztery uslugi.
Uklad PDF wg prosby: imie i nazwisko -> wprowadzone dane -> RYSUNEK kosmogramu
-> interpretacja natalna -> predykcje okresowe. Test pilnuje kolejnosci.
Dwie rzeczy, ktore wyszly dopiero przy skladaniu tego kawalka:
1. KOSMOGRAM NIE NADAWAL SIE DO PDF. Uzywa zmiennych CSS (var(--line)) i klasy
.glyph, ktorej font podaje styles.css — a samodzielny konwerter SVG→PDF nie zna
naszego arkusza. Wyszlyby czarne kreski BEZ SYMBOLI. Stad wariant „print":
konkretne kolory na bialym tle i font glifow wpisany wprost w rysunek. Przy
okazji zalatwia to etap 4 planu kola (wariant do druku).
2. UCIECZKA ZNAKOW LATEXA byla zepsuta — i zlapal to moj wlasny test. Zamiana
„\” na \textbackslash{} szla w tej samej petli co nawiasy, wiec kolejne
podmiany ucieklyby nawiasy dopiero co wstawione: wychodzilo
\textbackslash\{\}. Poprawka: ukosnik chowany pod znacznik zastepczy i
rozwijany na koncu. To nie kosmetyka — niezauwazony „%” komentuje RESZTE LINII,
wiec zdanie od modelu urywaloby sie w polowie, a PDF powstawalby normalnie,
tylko krotszy. Test na wrogim tekscie sprawdza tez, ze \end{document} ani
\input{} nie wyrwa sie z dokumentu.
Usluga nie zapisuje nic poza katalogiem tymczasowym, ktory sprzata po sobie;
w manifescie readOnlyRootFilesystem + emptyDir na /tmp. /health raportuje
obecnosc xelatex i rsvg-convert, zeby zepsuty obraz bylo widac od razu.
Zweryfikowane na zywo (TestClient uslugi render): zadanie bez szyfrowania
z POPRAWNYM tokenem -> 400; obcy klucz -> 400 i tajny opis nie wraca; wlasciwy
klucz -> zadanie dochodzi do aplikacji, tresci baz NIE MA na kablu, odpowiedz
zaszyfrowana. Manifesty przechodza kubectl apply --dry-run=server na zywym
klastrze.
CZEGO NIE SPRAWDZILEM: samej kompilacji PDF. W tym srodowisku nie ma ani TeX
Live, ani dzialajacego runtime'u kontenerow (docker CLI jest, daemon nie) —
probowalem zbudowac obraz i sie nie dalo. Sprawdzone jest wszystko dookola:
generowanie .tex, ucieczka znakow, szyfrowanie, kontrakt API, samowystarczalnosc
SVG. Pierwsze uruchomienie na klastrze trzeba obejrzec — dlatego PRE-24 zostaje
jako „W trakcie", nie „Zrobione".
Instrukcja wdrozenia: docs/wdrozenie-render-pdf.md (klucz, obraz, merge,
weryfikacja, znane ograniczenia).
Testy: 15 nowych (render) + 14 (lacze i wariant druku w prezentacji).
Calosc: prezentacja 131, logika 265/1 skip, render 15.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
421 lines
16 KiB
Python
421 lines
16 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".
|
||
"""
|
||
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"),
|
||
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, "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, stations=stations, zodiac=zodiac, tables=tables,
|
||
)
|
||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||
ctx["wheel_svg"] = chartwheel.render(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, "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"])
|
||
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)
|
||
|
||
wheel_svg = ""
|
||
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
|
||
|
||
wheel_svg = chartwheel.render(chart, theme="print")
|
||
except httpx.HTTPError as e:
|
||
# Brak rysunku nie może zablokować raportu — tekst jest ważniejszy.
|
||
log_note = _logic_error(e)
|
||
chart, wheel_svg = None, ""
|
||
data = {**data, "wheel_error": log_note}
|
||
|
||
report = {
|
||
"person": payload.get("person") or "",
|
||
"data": {**data, "moment_utc": label},
|
||
"wheel_svg": wheel_svg,
|
||
"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("/health")
|
||
def health() -> dict:
|
||
return {"status": "ok", "layer": "presentation"}
|