f33cdc88f4
PRZYCZYNA PUSTYCH ODPOWIEDZI NA ANTHROPICU (potwierdzona w dokumentacji API): domyslnym modelem byl `claude-sonnet-5`, ktory przy POMINIETYM parametrze `thinking` wlacza myslenie adaptacyjne, a `thinking.display` domyslnie jest "omitted". Tokeny myslenia licza sie do max_tokens, wiec przy LLM_MAX_TOKENS=2000 cala tura wychodzila jako bloki `thinking` z pustym tekstem — parser filtrowal type=="text" i zwracal pusty string. Opus 4.8 bez `thinking` nie mysli, wiec tam objaw by nie wystapil. Gwarancja niepustej odpowiedzi (wszyscy trzej dostawcy): - generate() to teraz PETLA, nie pojedynczy strzal: tura -> jesli urwana na limicie, dopisz ture „kontynuuj" w tej samej rozmowie i sklej tekst, - tura zlozona z samego myslenia traktowana jak urwana (nie jak pustka), - pusta i NIE urwana -> jedna proba z podpowiedzia, dopiero potem blad, - kontynuacja konczy sie tura UZYTKOWNIKA — Claude odrzuca prefill asystenta (400), - `thinking` konfigurowany JAWNIE (adaptive + effort=high; ANTHROPIC_THINKING=off). Okna kontekstu i rezerwa na odpowiedz (app/llm/limits.py): - tabela okien/limitow wyjscia per model + nadpisanie z ENV, - plan() liczy okno odpowiedzi jako okno - prompt - margines i NIGDY nie oddaje calego kontekstu promptowi, - Anthropic liczy tokeny DOKLADNIE (/v1/messages/count_tokens), reszta szacuje, - >90 tys. tokenow promptu -> ostrzezenie, ale wyslanie NADAL mozliwe i z pelnym oknem odpowiedzi. UI: suwak budzetu rozszerzony o „bardzo obszerny" i „maksymalny kontekst modelu" (liczony z okna wybranego modelu po odjeciu rezerwy); przy wyniku widac plan tokenow, liczbe tur i ostrzezenia. Domyslny model Anthropic: claude-opus-4-8. Testy: 170 passed / 1 skipped (logika) + 15 (prezentacja). Nowe testy pokrywaja sklejanie kontynuacji, brak prefillu asystenta, ture z samego myslenia, rezerwe na odpowiedz i prog ostrzezenia. Zweryfikowane e2e na atrapie Anthropica odtwarzajacej zgloszony objaw: 3 tury, obie czesci tekstu obecne. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
9.0 KiB
Python
237 lines
9.0 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,
|
||
provider=llm_provider,
|
||
)
|
||
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,
|
||
provider=llm_provider,
|
||
)
|
||
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"}
|