6466ab89a9
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m12s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 12m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
Ekran „Pliki" z trzema poziomami, wpiętymi w kontrolę dostępu z PRE-27:
„files" widzi listę i KLIKANIEM decyduje, z których baz program korzysta,
„files_input" dokłada wgrywanie i ARCHIWIZACJĘ,
administrator kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
STAN JEST TERAZ TRWAŁY. DAN-15 trzymał go w zmiennej DISABLED_BASES, bo warstwa
danych nie miała gdzie zapisywać — udział był montowany read-only. Skoro stan ma
być klikany, musi przetrwać restart, więc udział jest zapisywalny, a stan leży
w pliku obok baz (zapis atomowy: plik opisuje CAŁY zbiór, więc obcięcie w połowie
skasowałoby wiedzę o wszystkich naraz). DISABLED_BASES zostaje jako awaryjne
wyłączenie z konfiguracji i odsiewa DODATKOWO — nie odwrotnie, bo inaczej ktoś
z dostępem do ekranu włączyłby bazę wyłączoną świadomie na poziomie wdrożenia.
ARCHIWIZACJA NIE KASUJE. Plik zostaje na dysku, zamrożony, ze znacznikiem czasu;
znika wyłącznie z użytku. To najdalej idąca operacja osoby wgrywającej dane —
kasować może tylko administrator. Test sprawdza, że plik po archiwizacji nadal
istnieje, bo to jest cała istota tej operacji.
WALIDACJA JEST BRAMKĄ DO UŻYTKU, NIE FILTREM NA WEJŚCIU. Plik wgrany zostaje
NIEZALEŻNIE od wyniku — nie tracimy niczego, co ktoś wgrał. Zmienia się tylko to,
czy da się go włączyć. Sprawdzenie biegnie też w chwili włączania, nie tylko przy
wgrywaniu: reguły mogą się zmienić po fakcie.
O WALIDACJI WIE TYLKO ADMINISTRATOR. Pliki wstrzymane są odsiewane W WARSTWIE
DANYCH przy for_admin=False, a nie ukrywane w szablonie — gdyby dochodziły do
przeglądarki, wystarczyłby podgląd źródła, żeby poznać reguły. Odmowa włączenia
wraca do konta bez uprawnień BEZ POWODU, bo powód zdradza regułę. Sekcja reguł
nie trafia nawet do źródła strony. Test parametryzowany po obu niższych poziomach
szuka w odpowiedzi śladów mechanizmu i wymaga, żeby żadnego nie było.
Każdy plik ma policzony sha256 — tożsamość niezależna od nazwy. Wykorzystuje ją
już odrzucanie duplikatów, a w kroku drugim posłuży do pilnowania zgodności
lustra w SQL.
Przy okazji naprawiony błąd, który dopiero co bym wprowadził: Path("") to
Path("."), czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna`
zawsze wybierało pustą zmienną i zapisywało stan do katalogu bieżącego.
Wymaga zapisywalnego udziału — osobny PR w repo deploy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
628 lines
24 KiB
Python
628 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
|
|
|
|
|
|
class FileAction(BaseModel):
|
|
path: str
|
|
status: str = ""
|
|
by: str = ""
|
|
|
|
|
|
class FileUpload(BaseModel):
|
|
filename: str
|
|
content_b64: str
|
|
by: str = ""
|
|
|
|
|
|
class RulesUpdate(BaseModel):
|
|
rules: dict
|
|
|
|
|
|
def _files_call(fn, *args, **kw) -> dict:
|
|
from app.clients.data_client import DataClient
|
|
|
|
try:
|
|
return fn(DataClient(), *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))
|
|
|
|
|
|
@app.post("/files/status")
|
|
def files_status(a: FileAction) -> dict:
|
|
return _files_call(lambda c: c.files_status(a.path, a.status, a.by))
|
|
|
|
|
|
@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))
|
|
|
|
|
|
@app.post("/files/delete")
|
|
def files_delete(a: FileAction) -> dict:
|
|
return _files_call(lambda c: c.files_delete(a.path))
|
|
|
|
|
|
@app.post("/files/rules")
|
|
def files_rules(u: RulesUpdate) -> dict:
|
|
return _files_call(lambda c: c.files_rules(u.rules))
|
|
|
|
|
|
@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
|
|
|
|
|
|
@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(),
|
|
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
|