4ef90b30bc
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m38s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 24s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 24s
build / build (push) Successful in 59s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m53s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
Domyslnie model LOKALNY — prompt niesie oryginalne opisy z baz, wiec domyslnie NIC nie opuszcza sieci. Chmura wlaczana swiadomie (LOG-32). - app/llm/: LLMProvider (jak EphemerisEngine z LOG-24) + dwie implementacje. Lokalny serwer modelu (Ollama/vLLM/llama.cpp) i OpenAI mowia TYM SAMYM protokolem /chat/completions, wiec obsluguje je jedna klasa; Anthropic ma wlasny /v1/messages. Napisane na samym httpx — bez SDK openai/anthropic: mniej zaleznosci i pelna kontrola nad tym, co wychodzi z sieci. - Kazdy dostawca deklaruje `leaves_lan` — interfejs MUSI jawnie mowic, czy tresc baz opuszcza siec; UI na tej podstawie ostrzega. - Ponawianie z backoffem (429/5xx), timeouty, czytelne bledy zamiast stacktrace. - Klucz wylacznie z LLM_API_KEY (sekret), nigdy w repo ani w UI. - POST /chart/horoscope + GET /llm/health. WAZNE: prompt jest zwracany ZAWSZE — takze gdy model padnie lub brakuje klucza. Dzieki temu awaria dostawcy nie blokuje pracy: prompt mozna skopiowac i uzyc recznie. Prezentacja: wybor modelu (lokalny/Anthropic/OpenAI), przycisk „Napisz horoskop (AI)", wynik z informacja kto go napisal, czy dane opuscily siec, ile wskazan weszlo, oraz zastrzezenie ze to nie porada medyczna (PRE-15). Testy: 13 nowych (transport podstawiony — zaden prawdziwy model nie wolany), calosc 131 passed / 1 skipped. Zweryfikowane e2e na atrapie serwera modelu: horoskop napisany, leaves_lan=false, tokeny zliczone; a przy padnietym modelu / braku klucza / zlym dostawcy — czytelny blad i zachowany prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
235 lines
8.9 KiB
Python
235 lines
8.9 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
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
import httpx
|
||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||
from fastapi.responses import HTMLResponse
|
||
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 _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},
|
||
)
|
||
|
||
|
||
@app.post("/", response_class=HTMLResponse)
|
||
def chart_compute(
|
||
request: Request,
|
||
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"),
|
||
):
|
||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
||
"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, stations=stations, zodiac=zodiac,
|
||
)
|
||
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)
|
||
|
||
|
||
# ---------------- 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},
|
||
)
|
||
|
||
|
||
@app.post("/interpret", response_class=HTMLResponse)
|
||
def interpret_run(
|
||
request: Request,
|
||
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"),
|
||
):
|
||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||
"llm_provider": llm_provider}
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
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},
|
||
)
|
||
|
||
|
||
@app.post("/timeline", response_class=HTMLResponse)
|
||
def timeline_run(
|
||
request: Request,
|
||
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"),
|
||
):
|
||
form = {"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}
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||
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,
|
||
)
|
||
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,
|
||
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)
|
||
|
||
|
||
# ---------------- 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"}
|