Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ed8d9fb7a |
@@ -0,0 +1,368 @@
|
|||||||
|
"""Tabele pomocnicze horoskopu (LOG-23).
|
||||||
|
|
||||||
|
Zbiór wyliczeń, które astrolog czyta „obok" pozycji: bilans żywiołów i jakości,
|
||||||
|
faza Księżyca, stopnie krytyczne, dzień i godziny planetarne, syzygia prenatalna
|
||||||
|
oraz podziały (dwunastniki i nawamsa).
|
||||||
|
|
||||||
|
Dwie rzeczy wymagają prawdziwego liczenia, nie tabelki:
|
||||||
|
* **godziny planetarne** — są NIERÓWNE: dzień od wschodu do zachodu Słońca dzieli
|
||||||
|
się na 12 części, noc osobno. Bez faktycznego wschodu/zachodu wynik byłby
|
||||||
|
zmyślony, więc szukamy ich numerycznie (przejście wysokości Słońca przez −0°50′);
|
||||||
|
* **syzygia prenatalna** — ostatni nów albo pełnia PRZED urodzeniem; szukamy
|
||||||
|
wstecz momentu, w którym elongacja Księżyca przechodzi przez 0° lub 180°.
|
||||||
|
|
||||||
|
Moduł jest silnik-agnostyczny: potrzebuje tylko `positions()` i `sidereal()`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.zodiac import to_equatorial
|
||||||
|
|
||||||
|
# --- żywioły i jakości ------------------------------------------------------
|
||||||
|
ELEMENTS = ["Fire", "Earth", "Air", "Water"]
|
||||||
|
QUALITIES = ["Cardinal", "Fixed", "Mutable"]
|
||||||
|
ELEMENT_PL = {"Fire": "Ogień", "Earth": "Ziemia", "Air": "Powietrze", "Water": "Woda"}
|
||||||
|
QUALITY_PL = {"Cardinal": "Kardynalny", "Fixed": "Stały", "Mutable": "Zmienny"}
|
||||||
|
|
||||||
|
CLASSICAL = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]
|
||||||
|
MODERN = CLASSICAL + ["Uranus", "Neptune", "Pluto"]
|
||||||
|
|
||||||
|
# --- dzień i godziny planetarne --------------------------------------------
|
||||||
|
# Kolejność chaldejska: od najwolniejszej do najszybszej planety
|
||||||
|
CHALDEAN = ["Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon"]
|
||||||
|
# Władca dnia wg dnia tygodnia (0 = poniedziałek, jak w datetime.weekday())
|
||||||
|
WEEKDAY_RULER = ["Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun"]
|
||||||
|
|
||||||
|
# wysokość środka tarczy Słońca przy wschodzie/zachodzie (refrakcja + promień tarczy)
|
||||||
|
SUNRISE_ALTITUDE = -0.833
|
||||||
|
|
||||||
|
|
||||||
|
def element_of(sign: str) -> str:
|
||||||
|
return ELEMENTS[SIGNS.index(sign) % 4]
|
||||||
|
|
||||||
|
|
||||||
|
def quality_of(sign: str) -> str:
|
||||||
|
return QUALITIES[SIGNS.index(sign) % 3]
|
||||||
|
|
||||||
|
|
||||||
|
def tally(positions: list[dict], asc_sign: str | None = None,
|
||||||
|
modern: bool = True) -> dict:
|
||||||
|
"""Bilans żywiołów i jakości (LOG-23).
|
||||||
|
|
||||||
|
Liczymy w dwóch wariantach naraz, bo szkoły się różnią: 7 planet klasycznych
|
||||||
|
i 10 z nowożytnymi. Ascendent doliczany osobno — bywa traktowany jak punkt
|
||||||
|
równorzędny planetom.
|
||||||
|
"""
|
||||||
|
wanted = MODERN if modern else CLASSICAL
|
||||||
|
by_name = {p.get("name"): p for p in positions}
|
||||||
|
|
||||||
|
def count(names: list[str], with_asc: bool) -> dict:
|
||||||
|
elements = dict.fromkeys(ELEMENTS, 0)
|
||||||
|
qualities = dict.fromkeys(QUALITIES, 0)
|
||||||
|
used = []
|
||||||
|
for name in names:
|
||||||
|
p = by_name.get(name)
|
||||||
|
if not p or not p.get("sign"):
|
||||||
|
continue
|
||||||
|
elements[element_of(p["sign"])] += 1
|
||||||
|
qualities[quality_of(p["sign"])] += 1
|
||||||
|
used.append(name)
|
||||||
|
if with_asc and asc_sign:
|
||||||
|
elements[element_of(asc_sign)] += 1
|
||||||
|
qualities[quality_of(asc_sign)] += 1
|
||||||
|
used.append("Asc")
|
||||||
|
return {"elements": elements, "qualities": qualities,
|
||||||
|
"counted": used, "total": len(used)}
|
||||||
|
|
||||||
|
classical = count(CLASSICAL, False)
|
||||||
|
result = {
|
||||||
|
"classical_7": classical,
|
||||||
|
"with_modern_10": count(wanted, False),
|
||||||
|
"classical_7_plus_asc": count(CLASSICAL, True),
|
||||||
|
"with_modern_10_plus_asc": count(wanted, True),
|
||||||
|
}
|
||||||
|
# brakujące żywioły — klasyczne „no air" itd., podstawa pod scoring (LOG-21)
|
||||||
|
base = result["with_modern_10_plus_asc"]
|
||||||
|
result["missing_elements"] = [e for e, n in base["elements"].items() if n == 0]
|
||||||
|
result["missing_qualities"] = [q for q, n in base["qualities"].items() if n == 0]
|
||||||
|
result["labels"] = {"elements": ELEMENT_PL, "qualities": QUALITY_PL}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- faza Księżyca ----------------------------------------------------------
|
||||||
|
_PHASES = [
|
||||||
|
(0.0, "New Moon", "Nów"),
|
||||||
|
(45.0, "Waxing Crescent", "Sierp przybywający"),
|
||||||
|
(90.0, "First Quarter", "Pierwsza kwadra"),
|
||||||
|
(135.0, "Waxing Gibbous", "Garb przybywający"),
|
||||||
|
(180.0, "Full Moon", "Pełnia"),
|
||||||
|
(225.0, "Waning Gibbous", "Garb ubywający"),
|
||||||
|
(270.0, "Last Quarter", "Ostatnia kwadra"),
|
||||||
|
(315.0, "Waning Crescent", "Sierp ubywający"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def moon_phase(sun_lon: float, moon_lon: float) -> dict:
|
||||||
|
"""Faza Księżyca z elongacji (Księżyc − Słońce)."""
|
||||||
|
angle = norm360(moon_lon - sun_lon)
|
||||||
|
idx = int(((angle + 22.5) % 360.0) // 45.0)
|
||||||
|
_, name, name_pl = _PHASES[idx]
|
||||||
|
illumination = (1.0 - math.cos(math.radians(angle))) / 2.0
|
||||||
|
return {
|
||||||
|
"angle": round(angle, 4),
|
||||||
|
"phase": name,
|
||||||
|
"phase_pl": name_pl,
|
||||||
|
"illumination": round(illumination, 4),
|
||||||
|
"waxing": angle < 180.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- stopnie krytyczne ------------------------------------------------------
|
||||||
|
# klasyczne stopnie krytyczne zależą od jakości znaku
|
||||||
|
_CRITICAL = {"Cardinal": (0, 13, 26), "Fixed": (8, 21), "Mutable": (4, 17)}
|
||||||
|
CRITICAL_ORB = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def critical_degrees(positions: list[dict]) -> list[dict]:
|
||||||
|
"""Obiekty stojące na stopniach krytycznych, 0° albo 29° (anaretycznym)."""
|
||||||
|
out = []
|
||||||
|
for p in positions:
|
||||||
|
lon = p.get("decimal")
|
||||||
|
sign = p.get("sign")
|
||||||
|
if lon is None or not sign:
|
||||||
|
continue
|
||||||
|
deg = norm360(lon) - sign_index(lon) * 30.0
|
||||||
|
flags = []
|
||||||
|
for critical in _CRITICAL[quality_of(sign)]:
|
||||||
|
if abs(deg - critical) <= CRITICAL_ORB:
|
||||||
|
flags.append(f"stopień krytyczny {critical}° ({QUALITY_PL[quality_of(sign)].lower()})")
|
||||||
|
if deg >= 29.0:
|
||||||
|
flags.append("29° — stopień anaretyczny (koniec znaku)")
|
||||||
|
elif deg < 1.0:
|
||||||
|
flags.append("0° — wejście w znak")
|
||||||
|
if flags:
|
||||||
|
out.append({"name": p.get("name"), "sign": sign,
|
||||||
|
"in_sign": p.get("in_sign"), "flags": flags})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- podziały: dwunastnik i nawamsa ----------------------------------------
|
||||||
|
def dwadasamsa(lon: float) -> float:
|
||||||
|
"""12. część (dwadasamsa): znak dzielony na 12 po 2°30′, licząc od siebie."""
|
||||||
|
lon = norm360(lon)
|
||||||
|
start = sign_index(lon) * 30.0
|
||||||
|
return norm360(start + (lon - start) * 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
def navamsa(lon: float) -> float:
|
||||||
|
"""9. część (nawamsa): 108 podziałów po 3°20′ liczonych od 0° Barana."""
|
||||||
|
lon = norm360(lon)
|
||||||
|
part = int(lon // (30.0 / 9.0))
|
||||||
|
return norm360((part % 12) * 30.0 + (lon % (30.0 / 9.0)) * 9.0)
|
||||||
|
|
||||||
|
|
||||||
|
def divisional(positions: list[dict]) -> list[dict]:
|
||||||
|
"""Pozycje w podziałach 12. i 9. — obie tabele naraz."""
|
||||||
|
out = []
|
||||||
|
for p in positions:
|
||||||
|
lon = p.get("decimal")
|
||||||
|
if lon is None:
|
||||||
|
continue
|
||||||
|
d12, d9 = dwadasamsa(lon), navamsa(lon)
|
||||||
|
out.append({
|
||||||
|
"name": p.get("name"),
|
||||||
|
"d12_sign": SIGNS[sign_index(d12)], "d12_in_sign": in_sign(d12),
|
||||||
|
"d9_sign": SIGNS[sign_index(d9)], "d9_in_sign": in_sign(d9),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- wschód/zachód Słońca i godziny planetarne ------------------------------
|
||||||
|
def sun_altitude(engine, moment: ChartMoment) -> float:
|
||||||
|
"""Wysokość Słońca nad horyzontem [°] dla momentu i miejsca."""
|
||||||
|
ramc, eps = engine.sidereal(moment)
|
||||||
|
sun = engine.positions(moment, ["Sun"])[0]
|
||||||
|
ra, dec = to_equatorial(sun.longitude, sun.latitude, eps)
|
||||||
|
hour_angle = math.radians(norm360(ramc - ra))
|
||||||
|
phi, d = math.radians(moment.lat), math.radians(dec)
|
||||||
|
sin_alt = math.sin(d) * math.sin(phi) + math.cos(d) * math.cos(phi) * math.cos(hour_angle)
|
||||||
|
return math.degrees(math.asin(max(-1.0, min(1.0, sin_alt))))
|
||||||
|
|
||||||
|
|
||||||
|
def _at(moment: ChartMoment, when: datetime) -> ChartMoment:
|
||||||
|
return ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
|
||||||
|
|
||||||
|
|
||||||
|
def _crossings(engine, moment: ChartMoment, start: datetime, end: datetime,
|
||||||
|
step_minutes: int = 20) -> list[tuple[datetime, str]]:
|
||||||
|
"""Momenty przejścia Słońca przez horyzont w oknie [start, end].
|
||||||
|
|
||||||
|
Skan zgrubny + bisekcja — ten sam wzorzec co przy stacjach planet (LOG-03).
|
||||||
|
"""
|
||||||
|
out: list[tuple[datetime, str]] = []
|
||||||
|
step = timedelta(minutes=step_minutes)
|
||||||
|
t0 = start
|
||||||
|
f0 = sun_altitude(engine, _at(moment, t0)) - SUNRISE_ALTITUDE
|
||||||
|
while t0 < end:
|
||||||
|
t1 = min(t0 + step, end)
|
||||||
|
f1 = sun_altitude(engine, _at(moment, t1)) - SUNRISE_ALTITUDE
|
||||||
|
if f0 == 0.0 or (f0 < 0.0) != (f1 < 0.0):
|
||||||
|
lo, hi, flo = t0, t1, f0
|
||||||
|
for _ in range(40): # ~sekundowa dokładność
|
||||||
|
mid = lo + (hi - lo) / 2
|
||||||
|
fmid = sun_altitude(engine, _at(moment, mid)) - SUNRISE_ALTITUDE
|
||||||
|
if (flo < 0.0) != (fmid < 0.0):
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo, flo = mid, fmid
|
||||||
|
out.append((lo + (hi - lo) / 2, "sunrise" if f1 > f0 else "sunset"))
|
||||||
|
t0, f0 = t1, f1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def planetary_hours(engine, moment: ChartMoment) -> dict | None:
|
||||||
|
"""Dzień i godziny planetarne w porządku chaldejskim (LOG-23).
|
||||||
|
|
||||||
|
Godziny są NIERÓWNE: dzień (wschód→zachód) i noc (zachód→wschód) dzielą się
|
||||||
|
na 12 części każde. Doba planetarna zaczyna się o WSCHODZIE, nie o północy —
|
||||||
|
dlatego władcę dnia bierzemy z dnia tygodnia tego wschodu, który otworzył
|
||||||
|
bieżący okres.
|
||||||
|
|
||||||
|
Zwraca None dla dnia polarnego/nocy polarnej, gdzie wschód nie występuje.
|
||||||
|
"""
|
||||||
|
now = moment.when_utc
|
||||||
|
events = _crossings(engine, moment, now - timedelta(hours=30), now + timedelta(hours=30))
|
||||||
|
if not events:
|
||||||
|
return None # brak wschodu/zachodu w oknie
|
||||||
|
|
||||||
|
before = [e for e in events if e[0] <= now]
|
||||||
|
after = [e for e in events if e[0] > now]
|
||||||
|
if not before or not after:
|
||||||
|
return None
|
||||||
|
|
||||||
|
last_time, last_kind = before[-1]
|
||||||
|
next_time, _ = after[0]
|
||||||
|
|
||||||
|
daytime = last_kind == "sunrise"
|
||||||
|
period_start, period_end = last_time, next_time
|
||||||
|
# doba planetarna startuje o wschodzie: w nocy to wschód POPRZEDZAJĄCY zachód
|
||||||
|
day_start = last_time if daytime else next((t for t, k in reversed(before)
|
||||||
|
if k == "sunrise"), last_time)
|
||||||
|
|
||||||
|
length = (period_end - period_start) / 12
|
||||||
|
index = int((now - period_start) / length)
|
||||||
|
index = max(0, min(11, index))
|
||||||
|
|
||||||
|
day_ruler = WEEKDAY_RULER[day_start.weekday()]
|
||||||
|
hour_number = index if daytime else index + 12 # 0..23 od wschodu
|
||||||
|
ruler = CHALDEAN[(CHALDEAN.index(day_ruler) + hour_number) % 7]
|
||||||
|
|
||||||
|
hours = []
|
||||||
|
for i in range(12):
|
||||||
|
start = period_start + length * i
|
||||||
|
hours.append({
|
||||||
|
"index": i + 1,
|
||||||
|
"ruler": CHALDEAN[(CHALDEAN.index(day_ruler) + (i if daytime else i + 12)) % 7],
|
||||||
|
"start": start.isoformat(timespec="seconds"),
|
||||||
|
"end": (start + length).isoformat(timespec="seconds"),
|
||||||
|
"current": i == index,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"day_ruler": day_ruler,
|
||||||
|
"hour_ruler": ruler,
|
||||||
|
"hour_number": hour_number + 1,
|
||||||
|
"daytime": daytime,
|
||||||
|
"period": "dzień" if daytime else "noc",
|
||||||
|
"hour_length_minutes": round(length.total_seconds() / 60.0, 2),
|
||||||
|
"period_start": period_start.isoformat(timespec="seconds"),
|
||||||
|
"period_end": period_end.isoformat(timespec="seconds"),
|
||||||
|
"hours": hours,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- syzygia prenatalna -----------------------------------------------------
|
||||||
|
def prenatal_syzygy(engine, moment: ChartMoment, max_days: float = 32.0) -> dict | None:
|
||||||
|
"""Ostatni nów albo pełnia PRZED podanym momentem (LOG-23).
|
||||||
|
|
||||||
|
Szukamy wstecz przejścia elongacji przez 0° (nów) lub 180° (pełnia); bierzemy
|
||||||
|
to, które wypadło później. Cykl trwa ~29,5 dnia, więc okno 32 dni wystarcza.
|
||||||
|
"""
|
||||||
|
def elongation(when: datetime) -> float:
|
||||||
|
pts = {p.name: p.longitude for p in
|
||||||
|
engine.positions(_at(moment, when), ["Sun", "Moon"])}
|
||||||
|
return norm360(pts["Moon"] - pts["Sun"])
|
||||||
|
|
||||||
|
def signed(when: datetime, target: float) -> float:
|
||||||
|
"""Odległość od celu w [−180, 180] — zeruje się dokładnie w syzygii."""
|
||||||
|
return ((elongation(when) - target + 180.0) % 360.0) - 180.0
|
||||||
|
|
||||||
|
best: tuple[datetime, str] | None = None
|
||||||
|
for target, kind in ((0.0, "new_moon"), (180.0, "full_moon")):
|
||||||
|
step = timedelta(hours=6)
|
||||||
|
t1 = moment.when_utc
|
||||||
|
f1 = signed(t1, target)
|
||||||
|
scanned = timedelta()
|
||||||
|
while scanned < timedelta(days=max_days):
|
||||||
|
t0 = t1 - step
|
||||||
|
f0 = signed(t0, target)
|
||||||
|
if (f0 < 0.0) != (f1 < 0.0) and abs(f0 - f1) < 180.0:
|
||||||
|
lo, hi, flo = t0, t1, f0
|
||||||
|
for _ in range(40):
|
||||||
|
mid = lo + (hi - lo) / 2
|
||||||
|
fmid = signed(mid, target)
|
||||||
|
if (flo < 0.0) != (fmid < 0.0):
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo, flo = mid, fmid
|
||||||
|
found = lo + (hi - lo) / 2
|
||||||
|
if best is None or found > best[0]:
|
||||||
|
best = (found, kind)
|
||||||
|
break
|
||||||
|
t1, f1 = t0, f0
|
||||||
|
scanned += step
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
when, kind = best
|
||||||
|
pts = {p.name: p.longitude for p in engine.positions(_at(moment, when), ["Sun", "Moon"])}
|
||||||
|
lon = pts["Sun"] if kind == "new_moon" else pts["Moon"]
|
||||||
|
return {
|
||||||
|
"type": kind,
|
||||||
|
"type_pl": "nów" if kind == "new_moon" else "pełnia",
|
||||||
|
"when_utc": when.isoformat(timespec="seconds"),
|
||||||
|
"days_before_birth": round((moment.when_utc - when).total_seconds() / 86400.0, 3),
|
||||||
|
"sign": SIGNS[sign_index(lon)],
|
||||||
|
"in_sign": in_sign(lon),
|
||||||
|
"decimal": round(norm360(lon), 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- złożenie wszystkiego ---------------------------------------------------
|
||||||
|
def build_tables(engine, moment: ChartMoment, chart: dict,
|
||||||
|
heavy: bool = True) -> dict:
|
||||||
|
"""Komplet tabel dla policzonego horoskopu.
|
||||||
|
|
||||||
|
`heavy=False` pomija wyliczenia wymagające szukania numerycznego (godziny
|
||||||
|
planetarne, syzygia) — przydatne, gdy liczy się czas odpowiedzi.
|
||||||
|
"""
|
||||||
|
positions = chart.get("positions") or []
|
||||||
|
by_name = {p.get("name"): p for p in positions}
|
||||||
|
asc_sign = (chart.get("angles") or {}).get("Asc", {}).get("sign")
|
||||||
|
|
||||||
|
out: dict = {
|
||||||
|
"tally": tally(positions, asc_sign),
|
||||||
|
"critical_degrees": critical_degrees(positions),
|
||||||
|
"divisional": divisional(positions),
|
||||||
|
}
|
||||||
|
if "Sun" in by_name and "Moon" in by_name:
|
||||||
|
out["moon_phase"] = moon_phase(by_name["Sun"]["decimal"], by_name["Moon"]["decimal"])
|
||||||
|
if heavy:
|
||||||
|
out["planetary_hours"] = planetary_hours(engine, moment)
|
||||||
|
out["prenatal_syzygy"] = prenatal_syzygy(engine, moment)
|
||||||
|
return out
|
||||||
@@ -124,14 +124,7 @@ class _Driver:
|
|||||||
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def generate(self, prompt: str, max_tokens: int, on_event=None) -> Completion:
|
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||||
"""`on_event(dict)` dostaje zdarzenia postępu — UI pokazuje z nich log.
|
|
||||||
Raportujemy KAŻDĄ turę, bo to ona trwa; bez tego pasek postępu byłby
|
|
||||||
ozdobnikiem, a nie informacją."""
|
|
||||||
def emit(kind: str, message: str, **extra):
|
|
||||||
if on_event:
|
|
||||||
on_event({"type": kind, "message": message, **extra})
|
|
||||||
|
|
||||||
messages: list[dict] = [{"role": "user", "content": prompt}]
|
messages: list[dict] = [{"role": "user", "content": prompt}]
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
usage: dict = {}
|
usage: dict = {}
|
||||||
@@ -144,29 +137,9 @@ class _Driver:
|
|||||||
while turns <= MAX_CONTINUATIONS:
|
while turns <= MAX_CONTINUATIONS:
|
||||||
turns += 1
|
turns += 1
|
||||||
budget = max(256, min(remaining, TURN_TOKENS_CAP))
|
budget = max(256, min(remaining, TURN_TOKENS_CAP))
|
||||||
emit("turn_start",
|
|
||||||
f"Tura {turns}: wysyłam do modelu {self.model} (limit {budget} tokenów)…",
|
|
||||||
turn=turns)
|
|
||||||
started = time.monotonic()
|
|
||||||
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
||||||
took = time.monotonic() - started
|
|
||||||
_merge_usage(usage, turn_usage)
|
_merge_usage(usage, turn_usage)
|
||||||
|
remaining -= budget
|
||||||
# Odejmujemy tokeny FAKTYCZNIE wyprodukowane, nie zamówiony limit tury.
|
|
||||||
# Inaczej pierwsza tura zjadałaby cały budżet i urwana odpowiedź nigdy
|
|
||||||
# nie doczekałaby się kontynuacji — wracałby do użytkownika fragment
|
|
||||||
# udający całość.
|
|
||||||
produced = (turn_usage or {}).get("completion_tokens")
|
|
||||||
if produced is None:
|
|
||||||
produced = (turn_usage or {}).get("output_tokens")
|
|
||||||
if produced is None:
|
|
||||||
produced = max(1, int(len(text) / 3.6))
|
|
||||||
remaining -= max(1, int(produced))
|
|
||||||
|
|
||||||
emit("turn_end",
|
|
||||||
f"Tura {turns}: odebrano {len(text.strip())} znaków w {took:.1f}s"
|
|
||||||
+ (" — odpowiedź urwana, poproszę o dokończenie" if truncated else ""),
|
|
||||||
turn=turns, chars=len(text.strip()), truncated=truncated)
|
|
||||||
|
|
||||||
chunk = text.strip()
|
chunk = text.strip()
|
||||||
if chunk:
|
if chunk:
|
||||||
@@ -199,8 +172,6 @@ class _Driver:
|
|||||||
final = _join(parts)
|
final = _join(parts)
|
||||||
if not final:
|
if not final:
|
||||||
raise LLMError(_explain_empty(turns, usage, stop))
|
raise LLMError(_explain_empty(turns, usage, stop))
|
||||||
emit("generated", f"Gotowe: {len(final)} znaków w {turns} turach.",
|
|
||||||
chars=len(final), turns=turns)
|
|
||||||
|
|
||||||
usage["turns"] = turns
|
usage["turns"] = turns
|
||||||
return Completion(text=final, model=model_name, provider=self.name,
|
return Completion(text=final, model=model_name, provider=self.name,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class PositionsRequest(BaseModel):
|
|||||||
objects: list[str] | None = None
|
objects: list[str] | None = None
|
||||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||||
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
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
|
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +73,10 @@ def chart_positions(req: PositionsRequest) -> dict:
|
|||||||
st = find_stations(engine, moment, p["name"])
|
st = find_stations(engine, moment, p["name"])
|
||||||
if st:
|
if st:
|
||||||
p["stations"] = st
|
p["stations"] = st
|
||||||
|
if req.tables:
|
||||||
|
from app.engine.tables import build_tables
|
||||||
|
|
||||||
|
chart["tables"] = build_tables(engine, moment, chart)
|
||||||
return chart
|
return chart
|
||||||
|
|
||||||
|
|
||||||
@@ -265,72 +270,6 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
|
|||||||
return out
|
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")
|
@app.get("/llm/models")
|
||||||
def llm_models() -> dict:
|
def llm_models() -> dict:
|
||||||
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
"""Strumień postępu długiej operacji (NDJSON).
|
|
||||||
|
|
||||||
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
|
|
||||||
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
|
|
||||||
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
|
|
||||||
|
|
||||||
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
|
|
||||||
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
|
|
||||||
zwykłym `fetch()` i jest trywialny do sparsowania.
|
|
||||||
|
|
||||||
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
|
|
||||||
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
|
|
||||||
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import queue
|
|
||||||
import threading
|
|
||||||
import traceback
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
_HEARTBEAT_SECONDS = 10.0
|
|
||||||
_DONE = object()
|
|
||||||
|
|
||||||
|
|
||||||
def line(kind: str, message: str, **extra: Any) -> str:
|
|
||||||
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
|
|
||||||
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
|
|
||||||
|
|
||||||
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
|
|
||||||
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
|
|
||||||
— połączenie nigdy nie urywa się bez wyjaśnienia.
|
|
||||||
"""
|
|
||||||
events: queue.Queue = queue.Queue()
|
|
||||||
|
|
||||||
def emit(event: dict) -> None:
|
|
||||||
events.put(event)
|
|
||||||
|
|
||||||
def run() -> None:
|
|
||||||
try:
|
|
||||||
result = work(emit)
|
|
||||||
events.put({"type": "result", "message": "Gotowe.", "result": result})
|
|
||||||
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
|
|
||||||
events.put({
|
|
||||||
"type": "error",
|
|
||||||
"message": f"{type(e).__name__}: {e}",
|
|
||||||
"detail": traceback.format_exc(limit=3),
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
events.put(_DONE)
|
|
||||||
|
|
||||||
worker = threading.Thread(target=run, daemon=True)
|
|
||||||
worker.start()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
event = events.get(timeout=_HEARTBEAT_SECONDS)
|
|
||||||
except queue.Empty:
|
|
||||||
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
|
|
||||||
# (proxy, load balancer) nie uznały połączenia za martwe
|
|
||||||
yield line("ping", "…")
|
|
||||||
continue
|
|
||||||
if event is _DONE:
|
|
||||||
break
|
|
||||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
|
||||||
@@ -405,33 +405,3 @@ def test_max_budget_differs_between_models(monkeypatch):
|
|||||||
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
||||||
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
||||||
assert opus > haiku > local > 0
|
assert opus > haiku > local > 0
|
||||||
|
|
||||||
|
|
||||||
def test_turn_budget_counts_produced_not_requested(monkeypatch):
|
|
||||||
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
|
|
||||||
tokenow konczylo petle po jednej turze — urwany fragment wracal jako calosc."""
|
|
||||||
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
|
|
||||||
_chat("Zakonczenie. KONIEC", "stop")]
|
|
||||||
|
|
||||||
def handler(request):
|
|
||||||
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
|
||||||
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
|
|
||||||
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
|
|
||||||
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
|
|
||||||
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_reports_progress_events(monkeypatch):
|
|
||||||
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
|
|
||||||
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
|
|
||||||
monkeypatch.setattr(httpx, "Client", _mock_client(
|
|
||||||
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
|
|
||||||
events = []
|
|
||||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
|
|
||||||
"p", 8000, on_event=events.append)
|
|
||||||
kinds = [e["type"] for e in events]
|
|
||||||
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
|
|
||||||
assert kinds[-1] == "generated"
|
|
||||||
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
|
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Tabele pomocnicze horoskopu (LOG-23).
|
||||||
|
|
||||||
|
Wartości referencyjne dla horoskopu 30.04.1984 09:20 UTC, Kraków (50.0647N, 19.9450E)
|
||||||
|
sprawdzone wobec faktów NIEZALEŻNYCH od naszego kodu:
|
||||||
|
* 30.04.1984 to poniedziałek → władca dnia Księżyc; 5. godzina poniedziałku
|
||||||
|
w porządku chaldejskim to Słońce (Mo, Sa, Ju, Ma, Su),
|
||||||
|
* wschód/zachód dla Krakowa końcem kwietnia ≈ 5:18 / 19:57 czasu lokalnego
|
||||||
|
(CEST = UTC+2), czyli 03:18 / 17:57 UTC,
|
||||||
|
* pełnia poprzedzająca urodzenie: 15.04.1984 ok. 19:11 UTC.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.tables import (
|
||||||
|
build_tables,
|
||||||
|
critical_degrees,
|
||||||
|
dwadasamsa,
|
||||||
|
element_of,
|
||||||
|
moon_phase,
|
||||||
|
navamsa,
|
||||||
|
planetary_hours,
|
||||||
|
prenatal_syzygy,
|
||||||
|
quality_of,
|
||||||
|
tally,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def krakow():
|
||||||
|
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||||
|
lat=50.0647, lon=19.9450)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def tables(own_engine, krakow):
|
||||||
|
return build_tables(own_engine, krakow, build_chart(own_engine, krakow))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ żywioły i jakości
|
||||||
|
|
||||||
|
def test_element_and_quality_mapping():
|
||||||
|
assert element_of("Aries") == "Fire" and element_of("Cancer") == "Water"
|
||||||
|
assert quality_of("Aries") == "Cardinal" and quality_of("Taurus") == "Fixed"
|
||||||
|
assert quality_of("Gemini") == "Mutable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tally_matches_hand_count(tables):
|
||||||
|
"""Ręcznie przeliczone dla horoskopu referencyjnego (10 planet + Asc)."""
|
||||||
|
base = tables["tally"]["with_modern_10_plus_asc"]
|
||||||
|
assert base["elements"] == {"Fire": 4, "Earth": 4, "Air": 0, "Water": 3}
|
||||||
|
assert base["qualities"] == {"Cardinal": 4, "Fixed": 6, "Mutable": 1}
|
||||||
|
assert base["total"] == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_element_detected(tables):
|
||||||
|
"""Klasyczne „no air" — podstawa pod scoring siły (LOG-21)."""
|
||||||
|
assert tables["tally"]["missing_elements"] == ["Air"]
|
||||||
|
assert tables["tally"]["missing_qualities"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tally_variants_differ_by_object_count(tables):
|
||||||
|
t = tables["tally"]
|
||||||
|
assert t["classical_7"]["total"] == 7
|
||||||
|
assert t["with_modern_10"]["total"] == 10
|
||||||
|
assert t["classical_7_plus_asc"]["total"] == 8
|
||||||
|
assert t["with_modern_10_plus_asc"]["total"] == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_tally_sums_equal_counted_objects(tables):
|
||||||
|
for variant in ("classical_7", "with_modern_10", "with_modern_10_plus_asc"):
|
||||||
|
v = tables["tally"][variant]
|
||||||
|
assert sum(v["elements"].values()) == v["total"]
|
||||||
|
assert sum(v["qualities"].values()) == v["total"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- faza Księżyca
|
||||||
|
|
||||||
|
def test_moon_phase_reference_is_balsamic_new(tables):
|
||||||
|
"""Nów wypadł 1.05.1984, więc 30.04 Księżyc jest tuż przed nowiem."""
|
||||||
|
mp = tables["moon_phase"]
|
||||||
|
assert mp["phase"] == "New Moon"
|
||||||
|
assert 340.0 < mp["angle"] < 360.0
|
||||||
|
assert mp["illumination"] < 0.05
|
||||||
|
assert mp["waxing"] is False # elongacja > 180 = ubywa
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("angle,expected", [
|
||||||
|
(0.0, "New Moon"), (90.0, "First Quarter"), (180.0, "Full Moon"),
|
||||||
|
(270.0, "Last Quarter"), (46.0, "Waxing Crescent"), (300.0, "Waning Crescent"),
|
||||||
|
])
|
||||||
|
def test_moon_phase_buckets(angle, expected):
|
||||||
|
assert moon_phase(0.0, angle)["phase"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_moon_phase_illumination_extremes():
|
||||||
|
assert moon_phase(0.0, 0.0)["illumination"] == 0.0
|
||||||
|
assert moon_phase(0.0, 180.0)["illumination"] == 1.0
|
||||||
|
assert moon_phase(0.0, 90.0)["illumination"] == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- stopnie krytyczne
|
||||||
|
|
||||||
|
def test_critical_degrees_reference(tables):
|
||||||
|
found = {c["name"]: c["flags"] for c in tables["critical_degrees"]}
|
||||||
|
assert "Jupiter" in found # Cap 12°57' -> 13° kardynalny
|
||||||
|
assert any("13" in f for f in found["Jupiter"])
|
||||||
|
assert "Pluto" in found # Sco 0°28' -> wejście w znak
|
||||||
|
|
||||||
|
|
||||||
|
def test_anaretic_degree_flagged():
|
||||||
|
flags = critical_degrees([{"name": "X", "sign": "Leo", "decimal": 149.5,
|
||||||
|
"in_sign": "Leo 29°30'"}])
|
||||||
|
assert flags and any("anaretyczny" in f for f in flags[0]["flags"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_flags_for_ordinary_degree():
|
||||||
|
assert critical_degrees([{"name": "X", "sign": "Leo", "decimal": 135.0,
|
||||||
|
"in_sign": "Leo 15°"}]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- podziały
|
||||||
|
|
||||||
|
def test_dwadasamsa_starts_from_own_sign():
|
||||||
|
"""12. część liczy się OD znaku, w którym stoi punkt."""
|
||||||
|
assert dwadasamsa(0.0) == pytest.approx(0.0) # Ari 0 -> Ari
|
||||||
|
assert dwadasamsa(2.5) == pytest.approx(30.0) # Ari 2°30' -> Tau 0
|
||||||
|
assert dwadasamsa(30.0) == pytest.approx(30.0) # Tau 0 -> Tau
|
||||||
|
|
||||||
|
|
||||||
|
def test_navamsa_classic_starts():
|
||||||
|
"""Znaki kardynalne zaczynają od siebie, stałe od 9. znaku."""
|
||||||
|
assert navamsa(0.0) == pytest.approx(0.0) # Ari -> Ari
|
||||||
|
assert navamsa(30.0) == pytest.approx(270.0) # Tau -> Cap (9. od Byka)
|
||||||
|
assert navamsa(60.0) == pytest.approx(180.0) # Gem -> Lib
|
||||||
|
|
||||||
|
|
||||||
|
def test_divisional_covers_all_positions(tables, own_engine, krakow):
|
||||||
|
chart = build_chart(own_engine, krakow)
|
||||||
|
assert len(tables["divisional"]) == len(chart["positions"])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- dzień i godziny planetarne
|
||||||
|
|
||||||
|
def test_planetary_day_ruler_is_moon_on_monday(tables):
|
||||||
|
"""30.04.1984 to poniedziałek → władcą dnia jest Księżyc."""
|
||||||
|
assert tables["planetary_hours"]["day_ruler"] == "Moon"
|
||||||
|
|
||||||
|
|
||||||
|
def test_planetary_hour_matches_chaldean_sequence(tables):
|
||||||
|
"""Poniedziałek: 1=Mo, 2=Sa, 3=Ju, 4=Ma, 5=Su — urodzenie w 5. godzinie dnia."""
|
||||||
|
ph = tables["planetary_hours"]
|
||||||
|
assert ph["hour_number"] == 5
|
||||||
|
assert ph["hour_ruler"] == "Sun"
|
||||||
|
assert ph["daytime"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sunrise_sunset_match_krakow_late_april(tables):
|
||||||
|
"""Wschód ≈ 03:18 UTC, zachód ≈ 17:57 UTC (5:18 i 19:57 czasu lokalnego)."""
|
||||||
|
ph = tables["planetary_hours"]
|
||||||
|
assert ph["period_start"].startswith("1984-04-30T03:1")
|
||||||
|
assert ph["period_end"].startswith("1984-04-30T17:5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_planetary_hours_are_unequal_and_complete(tables):
|
||||||
|
"""Godziny są nierówne: wiosną dzienna trwa dłużej niż 60 minut."""
|
||||||
|
ph = tables["planetary_hours"]
|
||||||
|
assert ph["hour_length_minutes"] > 60.0
|
||||||
|
assert len(ph["hours"]) == 12
|
||||||
|
assert sum(1 for h in ph["hours"] if h["current"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_polar_night_returns_none(own_engine):
|
||||||
|
"""Za kołem podbiegunowym w grudniu Słońce nie wschodzi — brak godzin."""
|
||||||
|
polar = ChartMoment(when_utc=datetime(2024, 12, 21, 12, 0, tzinfo=timezone.utc),
|
||||||
|
lat=78.0, lon=15.0)
|
||||||
|
assert planetary_hours(own_engine, polar) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- syzygia prenatalna
|
||||||
|
|
||||||
|
def test_prenatal_syzygy_is_april_1984_full_moon(tables):
|
||||||
|
"""Rzeczywista pełnia: 15.04.1984 ok. 19:11 UTC."""
|
||||||
|
s = tables["prenatal_syzygy"]
|
||||||
|
assert s["type"] == "full_moon"
|
||||||
|
assert s["when_utc"].startswith("1984-04-15T19:1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prenatal_syzygy_precedes_birth_within_a_cycle(tables):
|
||||||
|
days = tables["prenatal_syzygy"]["days_before_birth"]
|
||||||
|
assert 0 < days < 29.6, "syzygia musi być w ostatnim cyklu przed urodzeniem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prenatal_syzygy_elongation_is_at_target(own_engine, krakow):
|
||||||
|
"""W znalezionym momencie elongacja MUSI wynosić 0° albo 180°."""
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
s = prenatal_syzygy(own_engine, krakow)
|
||||||
|
when = datetime.fromisoformat(s["when_utc"])
|
||||||
|
pts = {p.name: p.longitude for p in
|
||||||
|
own_engine.positions(ChartMoment(when_utc=when, lat=krakow.lat, lon=krakow.lon),
|
||||||
|
["Sun", "Moon"])}
|
||||||
|
elong = norm360(pts["Moon"] - pts["Sun"])
|
||||||
|
target = 0.0 if s["type"] == "new_moon" else 180.0
|
||||||
|
assert abs(((elong - target + 180.0) % 360.0) - 180.0) < 0.02
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ całość
|
||||||
|
|
||||||
|
def test_build_tables_light_skips_numeric_search(own_engine, krakow):
|
||||||
|
"""heavy=False pomija to, co wymaga szukania numerycznego."""
|
||||||
|
light = build_tables(own_engine, krakow, build_chart(own_engine, krakow), heavy=False)
|
||||||
|
assert "tally" in light and "moon_phase" in light
|
||||||
|
assert "planetary_hours" not in light and "prenatal_syzygy" not in light
|
||||||
@@ -39,6 +39,7 @@ class LogicClient:
|
|||||||
house_system: str = "whole_sign",
|
house_system: str = "whole_sign",
|
||||||
stations: bool = False,
|
stations: bool = False,
|
||||||
zodiac: str = "tropical",
|
zodiac: str = "tropical",
|
||||||
|
tables: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||||
payload = {
|
payload = {
|
||||||
@@ -49,9 +50,10 @@ class LogicClient:
|
|||||||
"house_system": house_system,
|
"house_system": house_system,
|
||||||
"stations": stations,
|
"stations": stations,
|
||||||
"zodiac": zodiac,
|
"zodiac": zodiac,
|
||||||
|
"tables": tables,
|
||||||
}
|
}
|
||||||
# stacje wymagają root-findów — dłuższy timeout
|
# stacje wymagają root-findów — dłuższy timeout
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
|
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout) as client:
|
||||||
r = client.post(f"{self.base_url}/chart/positions", json=payload, headers=_auth_headers())
|
r = client.post(f"{self.base_url}/chart/positions", json=payload, headers=_auth_headers())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
@@ -111,20 +113,6 @@ class LogicClient:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def horoscope_stream(self, payload: dict[str, Any]):
|
|
||||||
"""Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
|
|
||||||
|
|
||||||
Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
|
|
||||||
więc cisza na łączu nie zostanie wzięta za zerwanie.
|
|
||||||
"""
|
|
||||||
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
|
||||||
with client.stream("POST", f"{self.base_url}/chart/horoscope/stream",
|
|
||||||
json=payload, headers=_auth_headers()) as r:
|
|
||||||
r.raise_for_status()
|
|
||||||
for chunk in r.iter_lines():
|
|
||||||
if chunk:
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
def llm_models(self) -> dict[str, Any]:
|
def llm_models(self) -> dict[str, Any]:
|
||||||
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
@@ -79,17 +78,18 @@ def chart_compute(
|
|||||||
house_system: str = Form("whole_sign"),
|
house_system: str = Form("whole_sign"),
|
||||||
stations: bool = Form(False),
|
stations: bool = Form(False),
|
||||||
zodiac: str = Form("tropical"),
|
zodiac: str = Form("tropical"),
|
||||||
|
tables: bool = Form(False),
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
||||||
"zodiac": zodiac}
|
"zodiac": zodiac, "tables": tables}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
ctx["result"] = logic.positions(
|
ctx["result"] = logic.positions(
|
||||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
house_system=house_system, stations=stations, zodiac=zodiac,
|
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
||||||
)
|
)
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
@@ -229,61 +229,6 @@ def timeline_run(
|
|||||||
return templates.TemplateResponse(request, "timeline.html", ctx)
|
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
|
|
||||||
@app.post("/horoscope/stream")
|
|
||||||
def horoscope_stream(
|
|
||||||
profile: str = Form("natal"),
|
|
||||||
date: str = Form(...),
|
|
||||||
time: str = Form(...),
|
|
||||||
tz_offset: float = Form(0.0),
|
|
||||||
lat: float = Form(0.0),
|
|
||||||
lon: float = Form(0.0),
|
|
||||||
prompt_budget: str = Form("medium"),
|
|
||||||
llm_provider: str = Form("local"),
|
|
||||||
llm_model: str = Form(""),
|
|
||||||
from_date: str = Form(""),
|
|
||||||
to_date: str = Form(""),
|
|
||||||
):
|
|
||||||
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
|
|
||||||
|
|
||||||
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
|
|
||||||
przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku.
|
|
||||||
"""
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
try:
|
|
||||||
iso_utc, _ = _build_utc(date, time, tz_offset)
|
|
||||||
except ValueError as e:
|
|
||||||
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
|
||||||
|
|
||||||
payload: dict = {
|
|
||||||
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
|
|
||||||
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
|
|
||||||
}
|
|
||||||
if profile == "period" and from_date and to_date:
|
|
||||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
|
||||||
|
|
||||||
def relay():
|
|
||||||
try:
|
|
||||||
for raw in logic.horoscope_stream(payload):
|
|
||||||
try:
|
|
||||||
event = json.loads(raw)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if event.get("type") == "result":
|
|
||||||
html = templates.get_template("_prompt_result.html").render(
|
|
||||||
prompt_result=event.get("result") or {}
|
|
||||||
)
|
|
||||||
event["html"] = html
|
|
||||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
yield json.dumps({"type": "error", "message": _logic_error(e)},
|
|
||||||
ensure_ascii=False) + "\n"
|
|
||||||
|
|
||||||
return StreamingResponse(relay(), media_type="application/x-ndjson",
|
|
||||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||||||
@app.get("/geocode")
|
@app.get("/geocode")
|
||||||
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
// Okno postępu przy pisaniu horoskopu.
|
|
||||||
//
|
|
||||||
// Problem: generowanie trwa minutami, a zwykły POST formularza nie daje żadnego
|
|
||||||
// sygnału — aplikacja wygląda na zawieszoną. Zamiast udawanego paska postępu
|
|
||||||
// czytamy strumień RZECZYWISTYCH zdarzeń z serwera (NDJSON) i wypisujemy je
|
|
||||||
// jako log: budowa promptu, limity modelu, każda tura generowania.
|
|
||||||
//
|
|
||||||
// Degradacja: jeśli przeglądarka nie umie strumieniować `fetch`, nie przechwytujemy
|
|
||||||
// wysyłki — formularz idzie klasycznie i wszystko działa jak wcześniej, tylko bez okna.
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
const canStream = typeof fetch === 'function' && typeof ReadableStream === 'function' &&
|
|
||||||
typeof TextDecoder === 'function';
|
|
||||||
const form = document.querySelector('form[action="/interpret"], form[action="/timeline"]');
|
|
||||||
if (!canStream || !form) return;
|
|
||||||
|
|
||||||
const profile = form.getAttribute('action') === '/timeline' ? 'period' : 'natal';
|
|
||||||
const btn = form.querySelector('button[value="horoscope"]');
|
|
||||||
if (!btn) return;
|
|
||||||
|
|
||||||
// --- okno ---------------------------------------------------------------
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'progress-overlay';
|
|
||||||
overlay.hidden = true;
|
|
||||||
overlay.innerHTML =
|
|
||||||
'<div class="progress-box" role="dialog" aria-modal="true" aria-label="Postęp generowania">' +
|
|
||||||
'<div class="progress-head">' +
|
|
||||||
'<span class="progress-spinner" aria-hidden="true"></span>' +
|
|
||||||
'<strong id="progressTitle">Piszę horoskop…</strong>' +
|
|
||||||
'<span class="progress-clock" id="progressClock">0:00</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<ol class="progress-log" id="progressLog"></ol>' +
|
|
||||||
'<p class="muted small">Nie zamykaj tej karty — generowanie trwa na serwerze.</p>' +
|
|
||||||
'<div class="actions"><button type="button" class="ghost" id="progressClose" hidden>Zamknij</button></div>' +
|
|
||||||
'</div>';
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
const logEl = overlay.querySelector('#progressLog');
|
|
||||||
const clockEl = overlay.querySelector('#progressClock');
|
|
||||||
const titleEl = overlay.querySelector('#progressTitle');
|
|
||||||
const closeEl = overlay.querySelector('#progressClose');
|
|
||||||
let timer = null;
|
|
||||||
|
|
||||||
function addLine(text, kind) {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
if (kind) li.className = 'log-' + kind;
|
|
||||||
const now = new Date();
|
|
||||||
li.textContent = String(now.getHours()).padStart(2, '0') + ':' +
|
|
||||||
String(now.getMinutes()).padStart(2, '0') + ':' +
|
|
||||||
String(now.getSeconds()).padStart(2, '0') + ' ' + text;
|
|
||||||
logEl.appendChild(li);
|
|
||||||
logEl.scrollTop = logEl.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startClock() {
|
|
||||||
const t0 = Date.now();
|
|
||||||
timer = setInterval(function () {
|
|
||||||
const s = Math.floor((Date.now() - t0) / 1000);
|
|
||||||
clockEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function finish(title, allowClose) {
|
|
||||||
if (timer) { clearInterval(timer); timer = null; }
|
|
||||||
titleEl.textContent = title;
|
|
||||||
overlay.querySelector('.progress-spinner').style.visibility = 'hidden';
|
|
||||||
if (allowClose) closeEl.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeEl.addEventListener('click', function () { overlay.hidden = true; });
|
|
||||||
|
|
||||||
// --- przechwycenie wysyłki ----------------------------------------------
|
|
||||||
btn.addEventListener('click', function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!form.reportValidity()) return; // te same reguły co przy zwykłej wysyłce
|
|
||||||
|
|
||||||
const data = new FormData(form);
|
|
||||||
data.set('profile', profile);
|
|
||||||
|
|
||||||
logEl.innerHTML = '';
|
|
||||||
closeEl.hidden = true;
|
|
||||||
overlay.querySelector('.progress-spinner').style.visibility = '';
|
|
||||||
titleEl.textContent = 'Piszę horoskop…';
|
|
||||||
overlay.hidden = false;
|
|
||||||
startClock();
|
|
||||||
addLine('Wysyłam żądanie…');
|
|
||||||
|
|
||||||
fetch('/horoscope/stream', { method: 'POST', body: data })
|
|
||||||
.then(function (response) {
|
|
||||||
if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
function pump() {
|
|
||||||
return reader.read().then(function (chunk) {
|
|
||||||
if (chunk.done) return;
|
|
||||||
buffer += decoder.decode(chunk.value, { stream: true });
|
|
||||||
const lines = buffer.split('\n');
|
|
||||||
buffer = lines.pop(); // ostatni może być niepełny
|
|
||||||
lines.forEach(function (raw) {
|
|
||||||
if (!raw.trim()) return;
|
|
||||||
let ev;
|
|
||||||
try { ev = JSON.parse(raw); } catch (e) { return; }
|
|
||||||
if (ev.type === 'ping') return; // sam heartbeat, nie logujemy
|
|
||||||
if (ev.type === 'result') {
|
|
||||||
addLine(ev.message || 'Gotowe.', 'ok');
|
|
||||||
if (ev.html) {
|
|
||||||
const host = document.getElementById('promptResult');
|
|
||||||
if (host) host.innerHTML = ev.html;
|
|
||||||
}
|
|
||||||
finish('Gotowe', true);
|
|
||||||
overlay.hidden = true;
|
|
||||||
const host = document.getElementById('promptResult');
|
|
||||||
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
|
|
||||||
: ev.type === 'warn' ? 'warn' : null);
|
|
||||||
if (ev.type === 'error') finish('Nie udało się', true);
|
|
||||||
});
|
|
||||||
return pump();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return pump();
|
|
||||||
})
|
|
||||||
.catch(function (e) {
|
|
||||||
addLine('Połączenie przerwane: ' + e.message, 'err');
|
|
||||||
addLine('Możesz spróbować ponownie — nic nie zostało utracone.');
|
|
||||||
finish('Nie udało się', true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -88,27 +88,3 @@ textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizi
|
|||||||
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||||
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
/* Okno postępu przy generowaniu horoskopu */
|
|
||||||
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
z-index: 50; padding: 1rem; }
|
|
||||||
.progress-overlay[hidden] { display: none; }
|
|
||||||
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
|
||||||
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
|
||||||
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
|
||||||
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
|
||||||
color: var(--muted); font-size: .9rem; }
|
|
||||||
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
|
||||||
border: 2px solid var(--line); border-top-color: var(--accent);
|
|
||||||
animation: spin .8s linear infinite; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
|
||||||
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
|
||||||
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
|
||||||
max-height: 15rem; overflow-y: auto;
|
|
||||||
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
|
||||||
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
|
||||||
.progress-log li.log-ok { color: #7fd18b; }
|
|
||||||
.progress-log li.log-warn { color: #e0c060; }
|
|
||||||
.progress-log li.log-err { color: #ef6b6b; }
|
|
||||||
|
|||||||
@@ -36,4 +36,80 @@
|
|||||||
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="promptResult">{% include "_prompt_result.html" %}</div>
|
{% if prompt_result %}
|
||||||
|
{% set st = prompt_result.stats %}
|
||||||
|
|
||||||
|
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
|
||||||
|
{% if prompt_result.horoscope %}
|
||||||
|
<div class="meta">
|
||||||
|
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
|
||||||
|
model: {{ prompt_result.model }}
|
||||||
|
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
|
||||||
|
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
|
||||||
|
{% endif %}
|
||||||
|
{% if prompt_result.leaves_lan %}
|
||||||
|
· <strong class="retro">dane opuściły sieć</strong>
|
||||||
|
{% else %}
|
||||||
|
· dane nie opuściły sieci
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
|
||||||
|
<p class="muted small">
|
||||||
|
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
|
||||||
|
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.warnings %}
|
||||||
|
{% for w in prompt_result.warnings %}
|
||||||
|
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.token_plan %}
|
||||||
|
{% set tp = prompt_result.token_plan %}
|
||||||
|
<p class="muted small">
|
||||||
|
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
|
||||||
|
zarezerwowane na odpowiedź {{ tp.max_output }}
|
||||||
|
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
|
||||||
|
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.llm_error %}
|
||||||
|
<div class="error">
|
||||||
|
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
||||||
|
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
|
||||||
|
<div class="meta">
|
||||||
|
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
|
||||||
|
budżet: {{ st.budget }} ·
|
||||||
|
wskazań: <strong>{{ st.included }}</strong>
|
||||||
|
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
|
||||||
|
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if st.omitted %}
|
||||||
|
<p class="muted small">
|
||||||
|
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
|
||||||
|
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.data_error %}
|
||||||
|
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
|
||||||
|
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
{# Blok WYNIKU generowania (prompt/horoskop). Wydzielony, bo wstawia go także
|
|
||||||
okno postępu po zakończeniu strumienia — dzięki temu jest jedno źródło
|
|
||||||
prawdy dla wyglądu wyniku, niezależnie od drogi, którą przyszedł. #}
|
|
||||||
{% if prompt_result %}
|
|
||||||
{% set st = prompt_result.stats %}
|
|
||||||
|
|
||||||
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
|
|
||||||
{% if prompt_result.horoscope %}
|
|
||||||
<div class="meta">
|
|
||||||
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
|
|
||||||
model: {{ prompt_result.model }}
|
|
||||||
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
|
|
||||||
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
|
|
||||||
{% endif %}
|
|
||||||
{% if prompt_result.leaves_lan %}
|
|
||||||
· <strong class="retro">dane opuściły sieć</strong>
|
|
||||||
{% else %}
|
|
||||||
· dane nie opuściły sieci
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
|
|
||||||
</div>
|
|
||||||
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
|
|
||||||
<p class="muted small">
|
|
||||||
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
|
|
||||||
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if prompt_result.warnings %}
|
|
||||||
{% for w in prompt_result.warnings %}
|
|
||||||
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if prompt_result.token_plan %}
|
|
||||||
{% set tp = prompt_result.token_plan %}
|
|
||||||
<p class="muted small">
|
|
||||||
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
|
|
||||||
zarezerwowane na odpowiedź {{ tp.max_output }}
|
|
||||||
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
|
|
||||||
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if prompt_result.llm_error %}
|
|
||||||
<div class="error">
|
|
||||||
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
|
||||||
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
|
|
||||||
<div class="meta">
|
|
||||||
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
|
|
||||||
budżet: {{ st.budget }} ·
|
|
||||||
wskazań: <strong>{{ st.included }}</strong>
|
|
||||||
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
|
|
||||||
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if st.omitted %}
|
|
||||||
<p class="muted small">
|
|
||||||
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
|
|
||||||
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if prompt_result.data_error %}
|
|
||||||
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
|
|
||||||
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
|
|
||||||
</div>
|
|
||||||
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
|
|
||||||
{% endif %}
|
|
||||||
@@ -47,6 +47,8 @@
|
|||||||
<div class="opts">
|
<div class="opts">
|
||||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||||
licz stacje planet (wolniejsze)</label>
|
licz stacje planet (wolniejsze)</label>
|
||||||
|
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
|
||||||
|
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
|
||||||
</div>
|
</div>
|
||||||
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -142,6 +144,87 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.tables %}
|
||||||
|
{% set tb = result.tables %}
|
||||||
|
<div class="meta">Tabele dodatkowe (LOG-23)</div>
|
||||||
|
|
||||||
|
{% set bal = tb.tally.with_modern_10_plus_asc %}
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Żywioły</td>
|
||||||
|
{% for e, n in bal.elements.items() %}
|
||||||
|
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr><td>Jakości</td>
|
||||||
|
{% for q, n in bal.qualities.items() %}
|
||||||
|
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
|
||||||
|
{% endfor %}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
|
||||||
|
<p class="muted small">Brak:
|
||||||
|
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||||
|
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.moon_phase %}
|
||||||
|
<p class="muted small">
|
||||||
|
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
|
||||||
|
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
|
||||||
|
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
|
||||||
|
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.planetary_hours %}
|
||||||
|
{% set ph = tb.planetary_hours %}
|
||||||
|
<p class="muted small">
|
||||||
|
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
|
||||||
|
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
|
||||||
|
godzina trwa {{ ph.hour_length_minutes }} min
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.prenatal_syzygy %}
|
||||||
|
{% set s = tb.prenatal_syzygy %}
|
||||||
|
<p class="muted small">
|
||||||
|
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
|
||||||
|
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.critical_degrees %}
|
||||||
|
<div class="meta">Stopnie krytyczne</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in tb.critical_degrees %}
|
||||||
|
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
|
||||||
|
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details class="loc">
|
||||||
|
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in tb.divisional %}
|
||||||
|
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
|
||||||
|
<td class="mono">{{ d.d9_in_sign }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if result.cusps %}
|
{% if result.cusps %}
|
||||||
<details class="loc">
|
<details class="loc">
|
||||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||||
|
|||||||
@@ -91,5 +91,4 @@
|
|||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
<script src="/static/models.js"></script>
|
||||||
<script src="/static/progress.js"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -81,5 +81,4 @@
|
|||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
<script src="/static/models.js"></script>
|
||||||
<script src="/static/progress.js"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user