Compare commits

..

1 Commits

Author SHA1 Message Date
gitea 114b7eebdf feat(ui): okno postepu z logiem podczas pisania horoskopu
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m52s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 38s
Testy / Kontrola składni wszystkich warstw (push) Successful in 24s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m50s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 34s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s
Generowanie trwa minutami, a zwykly POST nie dawal zadnego sygnalu — aplikacja
wygladala na zawieszona. Teraz w trakcie pracy pojawia sie okno z logiem,
zegarem i spinnerem.

Log pokazuje RZECZYWISTE zdarzenia z serwera, nie udawany pasek postepu:
- app/progress.py — strumien NDJSON; praca leci w watku roboczym, generator
  odpompowuje kolejke, wiec zdarzenia docz w TRAKCIE pracy, nie na koncu;
  heartbeat co 10s, zeby proxy nie uznalo polaczenia za martwe,
- providers.generate(..., on_event) — raportuje kazda ture (start, czas trwania,
  liczba znakow, czy urwana), bo to tura trwa,
- POST /chart/horoscope/stream w logice + proxy /horoscope/stream w prezentacji.

Wynik: ostatnie zdarzenie niesie GOTOWY HTML wyrenderowany z tego samego
szablonu, ktory renderuje przeladowanie strony (_prompt_result.html wydzielony
z _prompt_block.html). Jedno zrodlo prawdy dla wygladu wyniku — okno wstawia go
bez przeladowania.

Degradacja: bez strumieniowania w przegladarce formularz idzie klasycznie
i wszystko dziala jak wczesniej, tylko bez okna. Blad polaczenia konczy sie
komunikatem w logu, nie cisza.

BLAD ZNALEZIONY PRZY TESCIE NA ZYWO: petla kontynuacji odejmowala od budzetu
ZAMOWIONY limit tury zamiast tokenow faktycznie wyprodukowanych — pierwsza tura
zjadala caly budzet, wiec urwana odpowiedz nigdy nie doczekala sie dokonczenia
i wracala do uzytkownika jako calosc. Naprawione i pokryte testem regresyjnym.

Testy: 176 passed / 1 skipped (logika) + 17 (prezentacja). Zweryfikowane na zywo
z wolna atrapa modelu: zdarzenia z poprawnymi czasami, okno z 11 liniami logu,
wynik wstawiony bez przeladowania strony.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:58:56 +02:00
15 changed files with 511 additions and 758 deletions
-368
View File
@@ -1,368 +0,0 @@
"""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
+31 -2
View File
@@ -124,7 +124,14 @@ class _Driver:
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
raise NotImplementedError
def generate(self, prompt: str, max_tokens: int) -> Completion:
def generate(self, prompt: str, max_tokens: int, on_event=None) -> 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}]
parts: list[str] = []
usage: dict = {}
@@ -137,9 +144,29 @@ class _Driver:
while turns <= MAX_CONTINUATIONS:
turns += 1
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)
took = time.monotonic() - started
_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()
if chunk:
@@ -172,6 +199,8 @@ class _Driver:
final = _join(parts)
if not final:
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
return Completion(text=final, model=model_name, provider=self.name,
+66 -5
View File
@@ -41,7 +41,6 @@ class PositionsRequest(BaseModel):
objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry
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
@@ -73,10 +72,6 @@ def chart_positions(req: PositionsRequest) -> dict:
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
@@ -270,6 +265,72 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
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.
+70
View File
@@ -0,0 +1,70 @@
"""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"
+30
View File
@@ -405,3 +405,33 @@ def test_max_budget_differs_between_models(monkeypatch):
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
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")
-216
View File
@@ -1,216 +0,0 @@
"""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,7 +39,6 @@ class LogicClient:
house_system: str = "whole_sign",
stations: bool = False,
zodiac: str = "tropical",
tables: bool = False,
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
@@ -50,10 +49,9 @@ class LogicClient:
"house_system": house_system,
"stations": stations,
"zodiac": zodiac,
"tables": tables,
}
# stacje wymagają root-findów — dłuższy timeout
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout) as client:
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
r = client.post(f"{self.base_url}/chart/positions", json=payload, headers=_auth_headers())
r.raise_for_status()
return r.json()
@@ -113,6 +111,20 @@ class LogicClient:
r.raise_for_status()
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]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
with httpx.Client(timeout=settings.http_timeout) as client:
+59 -4
View File
@@ -9,11 +9,12 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -78,18 +79,17 @@ def chart_compute(
house_system: str = Form("whole_sign"),
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
):
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
"zodiac": zodiac, "tables": tables}
"zodiac": zodiac}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
house_system=house_system, stations=stations, zodiac=zodiac,
)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
@@ -229,6 +229,61 @@ def timeline_run(
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) ----------------
@app.get("/geocode")
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
@@ -0,0 +1,133 @@
// 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,3 +88,27 @@ textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizi
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
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,80 +36,4 @@
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
</p>
{% 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 %}
<div id="promptResult">{% include "_prompt_result.html" %}</div>
@@ -0,0 +1,80 @@
{# 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,8 +47,6 @@
<div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
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>
{% 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">
@@ -144,87 +142,6 @@
</table>
{% 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 %}
<details class="loc">
<summary>Cusps domów ({{ result.house_system }})</summary>
@@ -91,4 +91,5 @@
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
<script src="/static/progress.js"></script>
{% endblock %}
@@ -81,4 +81,5 @@
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
<script src="/static/progress.js"></script>
{% endblock %}