10970c579f
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m19s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Testy astrodemo (pull_request) Failing after 0s
Testy / Testy astroklient (pull_request) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Failing after 0s
Testy / Testy astroklient (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Trzeci produkt drabiny: astrodemo (dwie funkcje) → astroklient → astrololo. Po trzech poprzednich krokach jest cienki, bo jest ZŁOŻENIEM, a nie kopią: własne main.py z ośmioma importami, a rdzeń — ekrany, szablony, zasoby — bierze z warstwy prezentacji przy budowaniu obrazu. Jedno źródło, dwa produkty; inaczej te same 2500 linii szablonów żyłyby w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu. CO MA: Horoskop, Interpretacje, Kalendarz, Synastria, Sygnifikatory, wgrywanie plików. Wyszukiwarka miejsca i strefa czasowa zgodnie z ustaleniem. CZEGO NIE MA I DLACZEGO NIE DA SIĘ WŁĄCZYĆ: plików usuniętych wg usun.txt nie ma w obrazie. Nie istnieje uprawnienie, którym dałoby się je odsłonić, bo katalog funkcji składa się ze ZGŁOSZEŃ ekranów obecnych w obrazie. To dlatego „każde konto dostaje wszystko, co ta usługa umie" jest tu bezpieczne i nie wymaga wypisywania listy: zbiór liczy się z katalogu, więc opisuje ten produkt. KONTA jak w astrodemo: z konfiguracji środowiska (ASTROKLIENT_USERS), jeden poziom dostępu, bez pliku kont i bez ekranu ich zakładania. Konta rozdziela się po to, żeby każde miało własną pulę plików. PULE PER KONTO — tu była realna dziura. Warstwa logiczna przenosiła pulę tylko przy raporcie i operacjach na plikach, więc Kalendarz i Sygnifikatory czytałyby CAŁY udział: jedno konto widziałoby pliki drugiego, mimo obietnicy izolacji. Domknięte: TimelineRequest i QueryRequest niosą teraz pulę, a QueryService buduje klienta danych na żądanie. Pula jedzie w każdym żądaniu w dół i bierze się z kontekstu ustawianego przy wejściu, nigdy z formularza. Test podstawia `tenant=ktos-inny` w POST i sprawdza, że w dół poszedł login zalogowanego. WARSTWA WSPÓLNA ROZDZIELONA OD POJĘCIA ADMINISTRATORA. base.html miał wpisany na sztywno warunek `can(request, 'admin')` i odsyłacz do ekranu kont — czyli w produkcie bez tego ekranu zostawał martwy link i nazwa czegoś, czego nie ma. Rejestr niesie teraz wymagane uprawnienie, a szablon dostaje gotową listę. Podstawa przestała też importować moduły służące jednemu ekranowi (konta, stany plików), bo produkt bez tego ekranu wlókł ich zależności. ZAPORA SŁOWNIKOWA NAD REALNYM DRZEWEM. Test buduje złożenie tak samo jak Dockerfile i szuka słów o funkcjach, których nie ma — w odpowiedziach ORAZ w plikach. Pierwsza wersja znalazła dziesięć trafień, w tym trzy moje własne docstringi WYLICZAJĄCE nieobecne funkcje: zdanie „nie ma tu generowania tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak przycisk. Po poprawkach: zero. Test ma kontrolę negatywną — podrzucony plik ma go wywrócić. usun.txt jest DANYMI, nie tekstem w Dockerfile: czyta go też test pilnujący, żeby zgadzał się ze złożeniem w main.py. Rozjazd znaczyłby albo martwy kod w obrazie, albo błąd dopiero przy uruchomieniu. CI: astroklient buduje się z KORZENIA repozytorium (jego Dockerfile sięga po rdzeń), a zmiana w warstwie prezentacji też go przebudowuje — bez tego jego obraz zostawałby ze starymi ekranami, a różnicy nie byłoby widać do zgłoszenia użytkownika. Testy: astroklient 12, presentation 368, astrodemo 28, logic 342, data 42, render 41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
645 lines
25 KiB
Python
645 lines
25 KiB
Python
"""Warstwa LOGICZNA — usługa HTTP.
|
|
|
|
W górę: udostępnia API dla warstwy prezentacji.
|
|
W dół: woła warstwę bazodanową (DataClient).
|
|
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
|
"""
|
|
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from app import link_crypto, security
|
|
from app.clients.data_client import DataClient
|
|
from app.models import QueryRequest, QueryResponse
|
|
from app.service import QueryService
|
|
|
|
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
|
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
|
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
|
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
|
app = FastAPI(title="astrololo · warstwa logiczna",
|
|
docs_url=None, redoc_url=None, openapi_url=None)
|
|
service = QueryService()
|
|
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
|
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
|
link_crypto.install(app, link_crypto.ENV_PRESENTATION_LOGIC, "logiczna")
|
|
|
|
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
|
_engine = None
|
|
|
|
|
|
def get_engine():
|
|
global _engine
|
|
if _engine is None:
|
|
from app.engine.factory import build_engine
|
|
|
|
_engine = build_engine()
|
|
return _engine
|
|
|
|
|
|
class PositionsRequest(BaseModel):
|
|
when_utc: datetime # moment w UTC (świadomy strefy)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
objects: list[str] | None = None
|
|
house_system: str = "whole_sign" # whole_sign | equal | porphyry (PRYMARNY — pod kosmogram)
|
|
house_systems: list[str] | None = None # PRE-05: dodatkowe systemy do porównania obok
|
|
aspect_orb: float = 8.0 # PRE-06: orb aspektów (konfigurowalny)
|
|
aspect_luminary_bonus: float = 2.0 # PRE-06: dodatkowy orb dla Słońca/Księżyca
|
|
aspect_minor: bool = False # PRE-06: dołóż aspekty poboczne (30/45/150°)
|
|
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
|
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
|
|
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
|
|
|
|
|
@app.post("/api/query", response_model=QueryResponse)
|
|
def query(req: QueryRequest) -> QueryResponse:
|
|
try:
|
|
return service.handle(req)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
|
|
|
|
|
@app.post("/chart/positions")
|
|
def chart_positions(req: PositionsRequest) -> dict:
|
|
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
|
|
opcjonalnie stacje planet (LOG-03, stations=true)."""
|
|
from app.engine.chart import build_chart
|
|
from app.engine.models import ChartMoment
|
|
|
|
engine = get_engine()
|
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
try:
|
|
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac,
|
|
house_systems=req.house_systems,
|
|
aspect_orb=req.aspect_orb,
|
|
aspect_luminary_bonus=req.aspect_luminary_bonus,
|
|
aspect_minor=req.aspect_minor)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
if req.stations:
|
|
from app.engine.stations import find_stations
|
|
|
|
for p in chart["positions"]:
|
|
st = find_stations(engine, moment, p["name"])
|
|
if st:
|
|
p["stations"] = st
|
|
if req.tables:
|
|
from app.engine.tables import build_tables
|
|
|
|
chart["tables"] = build_tables(engine, moment, chart)
|
|
return chart
|
|
|
|
|
|
class PersonMoment(BaseModel):
|
|
when_utc: datetime
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
|
|
|
|
class SynastryRequest(BaseModel):
|
|
"""Dwie osoby (PRE-04). Aspekty liczone MIĘDZY horoskopami, nie w środku."""
|
|
person_a: PersonMoment
|
|
person_b: PersonMoment
|
|
zodiac: str = "tropical"
|
|
aspect_orb: float = 8.0
|
|
aspect_luminary_bonus: float = 2.0
|
|
aspect_minor: bool = False
|
|
|
|
|
|
@app.get("/bases")
|
|
def bases() -> dict:
|
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
|
|
|
Przelot do warstwy danych — same METADANE, żadnej treści baz."""
|
|
from app.clients.data_client import DataClient
|
|
|
|
try:
|
|
return DataClient().bases()
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
|
|
|
|
|
# ── zarządzanie plikami baz (DAN-27) — czysty przelot ───────────────────
|
|
# Warstwa logiczna niczego tu nie rozstrzyga: uprawnienia zna PREZENTACJA
|
|
# (PRE-27), właścicielem plików jest warstwa DANYCH. Ta warstwa tylko przenosi,
|
|
# bo prezentacja nie ma prawa rozmawiać z danymi wprost.
|
|
|
|
class FilesQuery(BaseModel):
|
|
for_admin: bool = False
|
|
tenant: str = ""
|
|
|
|
|
|
class FileAction(BaseModel):
|
|
path: str
|
|
status: str = ""
|
|
by: str = ""
|
|
tenant: str = ""
|
|
|
|
|
|
class FileUpload(BaseModel):
|
|
filename: str
|
|
content_b64: str
|
|
by: str = ""
|
|
tenant: str = ""
|
|
|
|
|
|
class RulesUpdate(BaseModel):
|
|
rules: dict
|
|
tenant: str = ""
|
|
|
|
|
|
def _files_call(fn, *args, tenant: str = "", **kw) -> dict:
|
|
from app.clients.data_client import DataClient
|
|
|
|
try:
|
|
return fn(DataClient(tenant=tenant), *args, **kw)
|
|
except httpx.HTTPStatusError as e:
|
|
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
|
|
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
|
|
# się pokazać powód zamiast „usługa niedostępna".
|
|
raise HTTPException(status_code=e.response.status_code,
|
|
detail=_detail(e)) from e
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
|
|
|
|
|
def _detail(e: httpx.HTTPStatusError) -> str:
|
|
try:
|
|
return str(e.response.json().get("detail") or e)
|
|
except Exception: # noqa: BLE001
|
|
return str(e)
|
|
|
|
|
|
@app.post("/files")
|
|
def files_list(q: FilesQuery) -> dict:
|
|
return _files_call(lambda c: c.files_list(q.for_admin), tenant=q.tenant)
|
|
|
|
|
|
@app.post("/files/status")
|
|
def files_status(a: FileAction) -> dict:
|
|
return _files_call(lambda c: c.files_status(a.path, a.status, a.by), tenant=a.tenant)
|
|
|
|
|
|
@app.post("/files/upload")
|
|
def files_upload(u: FileUpload) -> dict:
|
|
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by), tenant=u.tenant)
|
|
|
|
|
|
@app.post("/files/delete")
|
|
def files_delete(a: FileAction) -> dict:
|
|
return _files_call(lambda c: c.files_delete(a.path), tenant=a.tenant)
|
|
|
|
|
|
@app.post("/files/rules")
|
|
def files_rules(u: RulesUpdate) -> dict:
|
|
return _files_call(lambda c: c.files_rules(u.rules), tenant=u.tenant)
|
|
|
|
|
|
@app.post("/chart/synastry")
|
|
def chart_synastry(req: SynastryRequest) -> dict:
|
|
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
|
osoby A vs planeta osoby B). Bez interpretacji z bazy — sama siatka aspektów."""
|
|
from app.engine import glyphs as GL
|
|
from app.engine.aspects import find_cross_aspects
|
|
from app.engine.chart import build_chart
|
|
from app.engine.models import ChartMoment
|
|
|
|
engine = get_engine()
|
|
a = build_chart(engine, ChartMoment(when_utc=req.person_a.when_utc,
|
|
lat=req.person_a.lat, lon=req.person_a.lon), zodiac=req.zodiac)
|
|
b = build_chart(engine, ChartMoment(when_utc=req.person_b.when_utc,
|
|
lat=req.person_b.lat, lon=req.person_b.lon), zodiac=req.zodiac)
|
|
cross = find_cross_aspects(
|
|
a["positions"], b["positions"], orb=req.aspect_orb,
|
|
luminary_bonus=req.aspect_luminary_bonus, minor=req.aspect_minor)
|
|
for c in cross:
|
|
c["glyph"] = GL.aspect_glyph(c["aspect"]) # symbol aspektu (LOG-22)
|
|
return {"engine": engine.name,
|
|
"person_a": {"positions": a["positions"]},
|
|
"person_b": {"positions": b["positions"]},
|
|
"aspects": cross}
|
|
|
|
|
|
@app.post("/chart/compare")
|
|
def chart_compare(req: PositionsRequest) -> dict:
|
|
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
|
|
|
|
Wymaga skonfigurowanego ENGINE_SWISSEPH_URL (silnik B). W przeciwnym razie
|
|
zwraca informację, że porównanie jest niedostępne.
|
|
"""
|
|
from app.engine.compare import compare_engines
|
|
from app.engine.factory import build_engine
|
|
from app.engine.models import ChartMoment
|
|
|
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
try:
|
|
report = compare_engines(build_engine("own"), build_engine("swisseph"), moment)
|
|
except (RuntimeError, httpx.HTTPError) as e:
|
|
raise HTTPException(status_code=503, detail=f"Silnik B niedostępny: {e}")
|
|
return report.summary()
|
|
|
|
|
|
class ReportRequest(BaseModel):
|
|
when_utc: datetime
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
limit: int = 5000
|
|
group: bool = False # grupowanie identycznych opisów
|
|
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
|
|
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
|
|
# prezentacji, bo to ona zna konta.
|
|
tenant: str = ""
|
|
|
|
|
|
@app.post("/chart/report")
|
|
def chart_report(req: ReportRequest) -> dict:
|
|
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
|
|
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
|
|
from app.engine.chart import build_chart
|
|
from app.engine.models import ChartMoment
|
|
from app.significators import build_report
|
|
|
|
engine = get_engine()
|
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
chart = build_chart(engine, moment) # pozycje z domami + aspekty
|
|
try:
|
|
report = build_report(
|
|
chart["positions"], DataClient(tenant=req.tenant),
|
|
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
|
per_object_limit=req.limit, group=req.group,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
|
return {"engine": engine.name, **report}
|
|
|
|
|
|
class PromptRequest(BaseModel):
|
|
"""Wejście generatora promptu (LOG-29/30)."""
|
|
profile: str = "natal" # natal | period
|
|
when_utc: datetime
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
budget: str = "medium" # concise | medium | extensive | huge | max
|
|
limit: int = 5000
|
|
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
|
|
model: str | None = None
|
|
# tylko dla profilu period:
|
|
from_date: str | None = None
|
|
to_date: str | None = None
|
|
techniques: list[str] | None = None
|
|
|
|
|
|
@app.post("/chart/prompt")
|
|
def chart_prompt(req: PromptRequest) -> dict:
|
|
"""Gotowy prompt do LLM z naszych wyliczeń (LOG-29) z budżetowaniem (LOG-30).
|
|
|
|
profile=natal → horoskop urodzeniowy (ekran Interpretacje)
|
|
profile=period → horoskop na wybrany okres (ekran Kalendarz)
|
|
|
|
Nie woła żadnego modelu — zwraca sam prompt i statystyki redukcji, żeby dało się
|
|
go obejrzeć i skopiować. Wysyłkę do modelu doda LOG-31.
|
|
"""
|
|
from app.engine.chart import build_chart
|
|
from app.engine.models import ChartMoment
|
|
from app.prompt import CHARS_PER_TOKEN, MAX_BUDGET, build_natal_prompt, build_period_prompt
|
|
|
|
engine = get_engine()
|
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
chart = build_chart(engine, moment)
|
|
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
|
|
data_error = None
|
|
|
|
# Budżet „maksymalny kontekst modelu": limit znaków liczymy z okna kontekstu
|
|
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
|
|
budget_chars = None
|
|
if req.budget == MAX_BUDGET:
|
|
from app.llm.factory import resolve_model
|
|
from app.llm.limits import prompt_token_budget
|
|
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
|
|
# więc nie może wymagać skonfigurowanego klucza API
|
|
provider_name, model_name = resolve_model(req.provider, req.model)
|
|
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
|
|
|
|
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
|
|
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
|
|
try:
|
|
if req.profile == "natal":
|
|
from app.significators import build_report
|
|
|
|
report: dict = {"objects": []}
|
|
try:
|
|
report = build_report(
|
|
chart["positions"], DataClient(),
|
|
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
|
per_object_limit=req.limit,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
data_error = f"Warstwa danych niedostępna: {e}"
|
|
out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
|
|
|
|
elif req.profile == "period":
|
|
if not (req.from_date and req.to_date):
|
|
raise HTTPException(422, "profile=period wymaga from_date i to_date")
|
|
from app.engine import houses as H
|
|
from app.engine.timeline import build_timeline
|
|
from app.significators import interpret_events
|
|
|
|
ramc, eps = engine.sidereal(moment)
|
|
points = {"Asc": H.compute_asc(ramc, eps, moment.lat), "MC": H.compute_mc(ramc, eps)}
|
|
for p in engine.positions(moment):
|
|
points[p.name] = p.longitude
|
|
events = build_timeline(engine, moment, points, req.from_date, req.to_date,
|
|
req.techniques)
|
|
try:
|
|
interpret_events(events, DataClient())
|
|
except httpx.HTTPError as e:
|
|
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
|
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
|
req.budget, label, budget_chars)
|
|
else:
|
|
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
|
except ValueError as e: # nieznany budżet
|
|
raise HTTPException(422, str(e))
|
|
|
|
out["engine"] = engine.name
|
|
if data_error:
|
|
out["data_error"] = data_error
|
|
return out
|
|
|
|
|
|
class HoroscopeRequest(PromptRequest):
|
|
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
|
|
max_tokens: int | None = None
|
|
|
|
|
|
@app.post("/chart/horoscope")
|
|
def chart_horoscope(req: HoroscopeRequest) -> dict:
|
|
"""Napisany horoskop (LOG-31): prompt z LOG-29/30 → model → gotowy tekst.
|
|
|
|
Zwraca TAKŻE użyty prompt — również gdy wywołanie modelu padnie — żeby dało się
|
|
go obejrzeć i użyć ręcznie. `leaves_lan` mówi, czy treść baz opuściła naszą sieć
|
|
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
|
"""
|
|
from app.llm.base import LLMError
|
|
from app.llm.factory import build_provider
|
|
from app.llm.limits import plan
|
|
|
|
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
|
try:
|
|
provider = build_provider(req.provider, req.model)
|
|
|
|
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
|
|
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
|
|
# w oknie kontekstu w ogóle zmieści się miejsce na horoskop.
|
|
prompt_tokens = provider.count_tokens(out["prompt"])
|
|
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
|
out["token_plan"] = budget
|
|
if budget["warnings"]:
|
|
out["warnings"] = budget["warnings"]
|
|
if not budget["fits"]:
|
|
out["llm_error"] = " ".join(budget["warnings"])
|
|
return out
|
|
|
|
result = provider.generate(out["prompt"], budget["max_output"])
|
|
except LLMError as e:
|
|
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
|
out["llm_error"] = str(e)
|
|
return out
|
|
|
|
out.update(
|
|
horoscope=result.text,
|
|
provider=result.provider,
|
|
model=result.model,
|
|
leaves_lan=result.leaves_lan,
|
|
usage=result.usage,
|
|
)
|
|
return out
|
|
|
|
|
|
@app.post("/chart/horoscope/stream")
|
|
def chart_horoscope_stream(req: HoroscopeRequest):
|
|
"""To samo co /chart/horoscope, ale strumieniuje POSTĘP w trakcie pracy.
|
|
|
|
Pisanie horoskopu trwa minutami — bez sygnału aplikacja wygląda na zawieszoną.
|
|
Strumień (NDJSON, jedna linia = jedno zdarzenie) niesie RZECZYWISTE etapy:
|
|
budowę promptu, limity modelu i każdą turę generowania. Ostatnie zdarzenie
|
|
(`result`) ma identyczny kształt co odpowiedź zwykłego endpointu.
|
|
"""
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from app.progress import stream
|
|
|
|
def work(emit) -> dict:
|
|
from app.llm.base import LLMError
|
|
from app.llm.factory import build_provider
|
|
from app.llm.limits import plan
|
|
|
|
emit({"type": "stage", "message": "Liczę horoskop i szukam wskazań w bazach…"})
|
|
out = chart_prompt(req)
|
|
st = out.get("stats", {})
|
|
emit({"type": "stage", "message":
|
|
f"Prompt gotowy: {st.get('chars', 0)} znaków, "
|
|
f"wskazań {st.get('included', 0)}"
|
|
+ (f", pominięto {st['omitted']}" if st.get("omitted") else "")})
|
|
if out.get("data_error"):
|
|
emit({"type": "warn", "message": out["data_error"]})
|
|
|
|
try:
|
|
provider = build_provider(req.provider, req.model)
|
|
emit({"type": "stage", "message":
|
|
f"Dostawca: {provider.name}, model: {provider.model}"
|
|
+ ("" if not provider.leaves_lan else " — dane opuszczają sieć")})
|
|
|
|
emit({"type": "stage", "message": "Liczę tokeny promptu…"})
|
|
prompt_tokens = provider.count_tokens(out["prompt"])
|
|
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
|
out["token_plan"] = budget
|
|
emit({"type": "stage", "message":
|
|
f"Prompt {prompt_tokens} tok. · okno modelu {budget['context_window']} · "
|
|
f"na odpowiedź {budget['max_output']}"})
|
|
for warning in budget["warnings"]:
|
|
emit({"type": "warn", "message": warning})
|
|
if budget["warnings"]:
|
|
out["warnings"] = budget["warnings"]
|
|
if not budget["fits"]:
|
|
out["llm_error"] = " ".join(budget["warnings"])
|
|
return out
|
|
|
|
result = provider.generate(out["prompt"], budget["max_output"], on_event=emit)
|
|
except LLMError as e:
|
|
emit({"type": "warn", "message": f"Model zawiódł: {e}"})
|
|
out["llm_error"] = str(e)
|
|
return out
|
|
|
|
out.update(horoscope=result.text, provider=result.provider, model=result.model,
|
|
leaves_lan=result.leaves_lan, usage=result.usage)
|
|
return out
|
|
|
|
return StreamingResponse(
|
|
stream(work),
|
|
media_type="application/x-ndjson",
|
|
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
|
)
|
|
|
|
|
|
@app.get("/llm/models")
|
|
def llm_models() -> dict:
|
|
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
|
|
|
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
|
|
dowolny identyfikator, do którego konto ma dostęp.
|
|
"""
|
|
from app.llm.catalog import catalog
|
|
from app.llm.factory import _DEFAULT_MODEL
|
|
|
|
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
|
|
|
|
|
|
@app.get("/llm/health")
|
|
def llm_health(provider: str | None = None) -> dict:
|
|
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
|
from app.llm.base import LLMError
|
|
from app.llm.factory import build_provider
|
|
|
|
try:
|
|
return build_provider(provider).health()
|
|
except LLMError as e:
|
|
return {"status": f"blad konfiguracji: {e}"}
|
|
|
|
|
|
class ProfectionsRequest(BaseModel):
|
|
when_utc: datetime # moment urodzenia (UTC)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
start_age: int = 0
|
|
count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy
|
|
|
|
|
|
@app.post("/chart/profections")
|
|
def chart_profections(req: ProfectionsRequest) -> dict:
|
|
"""Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo)."""
|
|
from app.engine import houses as H
|
|
from app.engine.models import ChartMoment
|
|
from app.engine.profections import profection_rows
|
|
|
|
engine = get_engine()
|
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
ramc, eps = engine.sidereal(natal)
|
|
points = {
|
|
"Asc": H.compute_asc(ramc, eps, natal.lat),
|
|
"MC": H.compute_mc(ramc, eps),
|
|
}
|
|
for p in engine.positions(natal, ["Sun", "Moon"]):
|
|
points[p.name] = p.longitude
|
|
rows = profection_rows(points, req.when_utc, req.start_age, req.count)
|
|
return {"engine": engine.name, "rows": rows}
|
|
|
|
|
|
class ReturnRequest(BaseModel):
|
|
when_utc: datetime # moment urodzenia (UTC)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
kind: str = "solar" # solar | lunar
|
|
around: datetime | None = None # data, wokół której szukać powrotu
|
|
|
|
|
|
@app.post("/chart/return")
|
|
def chart_return(req: ReturnRequest) -> dict:
|
|
"""Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment."""
|
|
from app.engine.chart import build_chart
|
|
from app.engine.models import ChartMoment
|
|
from app.engine.returns import find_return
|
|
|
|
if req.kind not in ("solar", "lunar"):
|
|
raise HTTPException(status_code=422, detail="kind: solar albo lunar")
|
|
engine = get_engine()
|
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
around = req.around or req.when_utc
|
|
hit = find_return(engine, req.kind, natal, around)
|
|
if hit is None:
|
|
raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu")
|
|
chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon))
|
|
return {"engine": engine.name, "kind": req.kind,
|
|
"return_utc": hit.isoformat(), **chart}
|
|
|
|
|
|
class FirdariaRequest(BaseModel):
|
|
when_utc: datetime # moment urodzenia (UTC)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
|
|
|
|
@app.post("/chart/firdaria")
|
|
def chart_firdaria(req: FirdariaRequest) -> dict:
|
|
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
|
|
from app.engine import houses as H
|
|
from app.engine.firdaria import firdaria
|
|
from app.engine.models import ChartMoment
|
|
|
|
engine = get_engine()
|
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
ramc, eps = engine.sidereal(natal)
|
|
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
|
|
sun = engine.positions(natal, ["Sun"])[0].longitude
|
|
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
|
|
|
|
|
|
class TimelineRequest(BaseModel):
|
|
when_utc: datetime # moment urodzenia (UTC)
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
from_date: str # zakres: YYYY-MM-DD
|
|
to_date: str
|
|
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
|
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
|
|
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
|
|
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
|
|
# prezentacji, bo to ona zna konta.
|
|
tenant: str = ""
|
|
|
|
|
|
@app.post("/chart/timeline")
|
|
def chart_timeline(req: TimelineRequest) -> dict:
|
|
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
|
|
|
|
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
|
|
"""
|
|
from app.engine import houses as H
|
|
from app.engine.models import ChartMoment
|
|
from app.engine.timeline import build_timeline
|
|
|
|
engine = get_engine()
|
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
|
ramc, eps = engine.sidereal(natal)
|
|
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
|
|
for p in engine.positions(natal):
|
|
points[p.name] = p.longitude
|
|
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
|
|
|
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
|
|
if req.interpret:
|
|
from app.significators import interpret_events
|
|
try:
|
|
interpret_events(events, DataClient())
|
|
except httpx.HTTPError as e:
|
|
out["data_error"] = f"Warstwa danych niedostępna: {e}"
|
|
out.update(count=len(events), events=events)
|
|
return out
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
info = {"status": "ok", "layer": "logic"}
|
|
try:
|
|
info["data_layer"] = DataClient().health()
|
|
except httpx.HTTPError as e:
|
|
info["data_layer"] = {"status": "down", "error": str(e)}
|
|
return info
|