2d7775f9b3
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m17s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astroklienta (wersja demo) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astroklienta (wersja demo) (pull_request) Successful in 9m26s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
Demo ma być rozdawane szeroko i różnym osobom, więc pierwsza wersja — jedno konto na produkcyjnej warstwie danych — nie nadawała się do użycia: każdy dostawałby dostęp do oryginalnych baz, a wgrania jednego klienta widzieliby wszyscy. IZOLACJA OD PRODUKCJI. Warstwa danych i logiczna demo są osobne (manifesty w repo deploy). Osobna musi być TEŻ LOGICZNA, bo zna ona jeden adres warstwy danych — demo korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy. PULE PER KONTO w warstwie danych. Zapytanie i lista plików niosą nazwę puli; puste = cały udział, czyli produkcja działa dokładnie jak dotąd i o pulach nic nie wie. Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne w nazwie katalogu — „../..” albo ukośnik wyprowadziłyby zapytanie wprost do cudzych baz, więc sito ZAMIENIA podejrzane znaki zamiast ufać, że nikt ich nie poda. PULA MUSI BYĆ W KLUCZU CACHE ZAPYTAŃ. Bez tego wynik policzony dla jednego konta trafiłby z cache do drugiego — cicha wymiana treści baz między klientami, niewidoczna w logach i nie do wykrycia z zewnątrz. Osobny test tego pilnuje. PULA WYNIKA Z LOGINU, nigdy z żądania. Klient warstwy logicznej jest budowany per żądanie i związany z pulą zalogowanej osoby; gdyby nazwa przychodziła z formularza, wystarczyłoby podstawić cudzy login. Test wysyła `tenant`, `user` i `login` w polach formularza i sprawdza, że nie mają na nią wpływu. Pulę wstrzykujemy w INSTANCJĘ klienta, nie w sygnatury metod. Argumentem trzeba by ją przeprowadzić przez protokół DataSource i build_report — kod, który o kontach nie ma prawa nic wiedzieć — a każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam. Konta demo to lista `login:sekret` (DEMO_USERS), bo jedno wspólne konto oznaczałoby wspólną pulę. Format i skrypt haseł te same, co w głównej aplikacji. Pula klienta to JEDEN KATALOG, więc przejście na pełną wersję nie oznacza utraty wgrań — procedurę importu opisuje runbook w repo deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
636 lines
24 KiB
Python
636 lines
24 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
|
|
|
|
app = FastAPI(title="astrololo · warstwa logiczna")
|
|
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)
|
|
|
|
|
|
@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
|