04b26afa6d
Pierwszy krok ustalonej kolejnosci: prompt z podgladem, uzyteczny od razu BEZ integracji API (ta przyjdzie w LOG-31). LOG-29 — app/prompt.py: - profil natal (ekran Interpretacje) i period (ekran Kalendarz), - prompt po polsku: zadanie -> dane horoskopu (pozycje, osie, Lots, aspekty, sekta, zodiak) -> wskazania z baz wg wagi -> instrukcje -> zastrzezenie, - twarde reguly: kazda teza musi cytowac konkretny sygnifikator, zakaz wychodzenia poza dostarczone dane, jawne wskazanie sprzecznosci, - deterministyczny: ten sam horoskop + budzet = ten sam prompt. LOG-30 — redukcja do budzetu (concise/medium/extensive): dedup -> grupowanie z licznikiem -> sortowanie wg punktacji sily (LOG-21) -> obciecie ogona (jednostka = CALE wskazanie) -> skracanie dlugich opisow. Statystyki zwracaja ile weszlo/pominieto i jaki byl prog — takze w tresci promptu, zeby model wiedzial, ze widzi wybor. POST /chart/prompt — zwraca sam prompt + statystyki, bez wolania modelu. Prezentacja: przycisk „Generuj prompt (AI)" na obu ekranach, wybor budzetu, pole z promptem + kopiowanie (z fallbackiem dla http bez secure context). WAZNE (znalezione przy tescie e2e): padnieta warstwa danych zabiera tylko wskazania — horoskop i OS CZASU zostaja, bo sa czysto obliczeniowe. Wczesniej blad bazy gubil cala osie czasu, czyniac prognoze okresowa bezuzyteczna. Zabezpieczone testem. Testy: 19 nowych, calosc 118 passed / 1 skipped. Zweryfikowane e2e w przegladarce: profil natal (3340 znakow) i period (15 zdarzen, 4728 znakow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
328 lines
12 KiB
Python
328 lines
12 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.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
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()
|
|
|
|
# --- 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
|
|
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
|
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)
|
|
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
|
|
return chart
|
|
|
|
|
|
@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"), 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
|
|
limit: int = 5000
|
|
# 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 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
|
|
|
|
# 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"), 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)
|
|
|
|
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)
|
|
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 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
|