From 114b7eebdfbd443f24c2781e2c4e2f824ac35c61 Mon Sep 17 00:00:00 2001
From: migatu
Date: Wed, 22 Jul 2026 22:58:56 +0200
Subject: [PATCH 1/3] feat(ui): okno postepu z logiem podczas pisania horoskopu
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
services/logic/app/llm/providers.py | 33 ++++-
services/logic/app/main.py | 66 +++++++++
services/logic/app/progress.py | 70 +++++++++
services/logic/tests/test_llm.py | 30 ++++
.../presentation/app/clients/logic_client.py | 14 ++
services/presentation/app/main.py | 58 +++++++-
services/presentation/app/static/progress.js | 133 ++++++++++++++++++
services/presentation/app/static/styles.css | 24 ++++
.../app/templates/_prompt_block.html | 78 +---------
.../app/templates/_prompt_result.html | 80 +++++++++++
.../presentation/app/templates/interpret.html | 1 +
.../presentation/app/templates/timeline.html | 1 +
12 files changed, 508 insertions(+), 80 deletions(-)
create mode 100644 services/logic/app/progress.py
create mode 100644 services/presentation/app/static/progress.js
create mode 100644 services/presentation/app/templates/_prompt_result.html
diff --git a/services/logic/app/llm/providers.py b/services/logic/app/llm/providers.py
index 1eb5f6a..53bf049 100644
--- a/services/logic/app/llm/providers.py
+++ b/services/logic/app/llm/providers.py
@@ -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,
diff --git a/services/logic/app/main.py b/services/logic/app/main.py
index 9f88a5a..04824cc 100644
--- a/services/logic/app/main.py
+++ b/services/logic/app/main.py
@@ -265,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.
diff --git a/services/logic/app/progress.py b/services/logic/app/progress.py
new file mode 100644
index 0000000..cc4d16e
--- /dev/null
+++ b/services/logic/app/progress.py
@@ -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"
diff --git a/services/logic/tests/test_llm.py b/services/logic/tests/test_llm.py
index 25659b5..1041ca5 100644
--- a/services/logic/tests/test_llm.py
+++ b/services/logic/tests/test_llm.py
@@ -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")
diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py
index 7df6731..9e5ed1e 100644
--- a/services/presentation/app/clients/logic_client.py
+++ b/services/presentation/app/clients/logic_client.py
@@ -111,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:
diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index 182b548..3243491 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -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
@@ -228,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")):
diff --git a/services/presentation/app/static/progress.js b/services/presentation/app/static/progress.js
new file mode 100644
index 0000000..197a55a
--- /dev/null
+++ b/services/presentation/app/static/progress.js
@@ -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 =
+ '' +
+ '
' +
+ ' ' +
+ 'Piszę horoskop… ' +
+ '0:00 ' +
+ '
' +
+ '
' +
+ '
Nie zamykaj tej karty — generowanie trwa na serwerze.
' +
+ '
Zamknij
' +
+ '
';
+ 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);
+ });
+ });
+});
diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css
index 20935c4..552b5ea 100644
--- a/services/presentation/app/static/styles.css
+++ b/services/presentation/app/static/styles.css
@@ -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; }
diff --git a/services/presentation/app/templates/_prompt_block.html b/services/presentation/app/templates/_prompt_block.html
index 363fc6d..440be4c 100644
--- a/services/presentation/app/templates/_prompt_block.html
+++ b/services/presentation/app/templates/_prompt_block.html
@@ -36,80 +36,4 @@
sieć. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
-{% if prompt_result %}
- {% set st = prompt_result.stats %}
-
- {# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
- {% if prompt_result.horoscope %}
-
- Horoskop napisany przez: {{ prompt_result.provider }} ·
- 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 %}
- · dane opuściły sieć
- {% else %}
- · dane nie opuściły sieci
- {% endif %}
-
-
- Kopiuj horoskop
-
-
-
- Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
- Nie stanowi porady medycznej, prawnej ani finansowej.
-
- {% endif %}
-
- {% if prompt_result.warnings %}
- {% for w in prompt_result.warnings %}
- Uwaga: {{ w }}
- {% endfor %}
- {% endif %}
-
- {% if prompt_result.token_plan %}
- {% set tp = prompt_result.token_plan %}
-
- 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 %}
-
- {% endif %}
-
- {% if prompt_result.llm_error %}
-
- Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}
- Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
-
- {% endif %}
-
- {# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
-
- Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
- budżet: {{ st.budget }} ·
- wskazań: {{ st.included }}
- {% if st.omitted %}· pominięto: {{ st.omitted }} {% endif %}
- {% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
-
-
- {% if st.omitted %}
-
- Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
- Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
-
- {% endif %}
-
- {% if prompt_result.data_error %}
- {{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.
- {% endif %}
-
-
- Kopiuj prompt
- Możesz też wkleić go samodzielnie do ChatGPT lub Claude.
-
-
-{% endif %}
+{% include "_prompt_result.html" %}
diff --git a/services/presentation/app/templates/_prompt_result.html b/services/presentation/app/templates/_prompt_result.html
new file mode 100644
index 0000000..f6f22e2
--- /dev/null
+++ b/services/presentation/app/templates/_prompt_result.html
@@ -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 %}
+
+ Horoskop napisany przez: {{ prompt_result.provider }} ·
+ 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 %}
+ · dane opuściły sieć
+ {% else %}
+ · dane nie opuściły sieci
+ {% endif %}
+
+
+ Kopiuj horoskop
+
+
+
+ Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
+ Nie stanowi porady medycznej, prawnej ani finansowej.
+
+ {% endif %}
+
+ {% if prompt_result.warnings %}
+ {% for w in prompt_result.warnings %}
+ Uwaga: {{ w }}
+ {% endfor %}
+ {% endif %}
+
+ {% if prompt_result.token_plan %}
+ {% set tp = prompt_result.token_plan %}
+
+ 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 %}
+
+ {% endif %}
+
+ {% if prompt_result.llm_error %}
+
+ Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}
+ Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
+
+ {% endif %}
+
+ {# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
+
+ Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
+ budżet: {{ st.budget }} ·
+ wskazań: {{ st.included }}
+ {% if st.omitted %}· pominięto: {{ st.omitted }} {% endif %}
+ {% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
+
+
+ {% if st.omitted %}
+
+ Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
+ Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
+
+ {% endif %}
+
+ {% if prompt_result.data_error %}
+ {{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.
+ {% endif %}
+
+
+ Kopiuj prompt
+ Możesz też wkleić go samodzielnie do ChatGPT lub Claude.
+
+
+{% endif %}
diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html
index 584c4d5..d919fe3 100644
--- a/services/presentation/app/templates/interpret.html
+++ b/services/presentation/app/templates/interpret.html
@@ -91,4 +91,5 @@
+
{% endblock %}
diff --git a/services/presentation/app/templates/timeline.html b/services/presentation/app/templates/timeline.html
index fbe01e5..418b142 100644
--- a/services/presentation/app/templates/timeline.html
+++ b/services/presentation/app/templates/timeline.html
@@ -81,4 +81,5 @@
+
{% endblock %}
From 929b69123812a1ac062630ffd8c9d796f136b37b Mon Sep 17 00:00:00 2001
From: migatu
Date: Thu, 23 Jul 2026 14:24:25 +0200
Subject: [PATCH 2/3] fix(ui): okno postepu nad mapa, nie pod nia
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Modal postepu przy pisaniu horoskopu renderowal sie POD kontrolkami mapy —
przyciski zoomu i atrybucja OSM przebijaly przez zaciemnione tlo okna.
Przyczyna: Leaflet trzyma kontrolki (.leaflet-top/.leaflet-bottom) na
z-index:1000, a .leaflet-container NIE tworzy wlasnego kontekstu stackowania
(position:relative bez z-index), wiec te 1000 trafia wprost do korzenia. Zaden
przodek mapy (form, .geo, .geo-map, .wrap) tez kontekstu nie tworzy. Okno
postepu mialo z-index:50 — czyli ladowalo pod mapa.
Poprawka: z-index okna 50 -> 1200 (zapas nad 1000). Jedna wartosc.
Zweryfikowane w przegladarce na PRAWDZIWYCH arkuszach (leaflet.css + styles.css):
- runtime elementFromPoint w punkcie kontrolek zoomu: przed = element mapy na
wierzchu („MAPA PRZYKRYWA MODAL"), po = overlay na wierzchu („MODAL NA WIERZCHU"),
- wizualnie: kontrolki zoomu i © OSM przed poprawka jasne na wierzchu, po —
przygaszone pod modalem.
Niezalezne od PRE-16 (nie rusza styles.css) — mozna zmergeowac przed nim.
Co-Authored-By: Claude Opus 4.8
---
services/presentation/app/static/styles.css | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css
index 552b5ea..0f218c2 100644
--- a/services/presentation/app/static/styles.css
+++ b/services/presentation/app/static/styles.css
@@ -90,9 +90,13 @@ textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizi
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
/* Okno postępu przy generowaniu horoskopu */
+/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
+ atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
+ kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
+ wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
.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; }
+ z-index: 1200; 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); }
From 473d059a6a49d8ed2e61acd457ccd92575cb13a4 Mon Sep 17 00:00:00 2001
From: migatu
Date: Wed, 22 Jul 2026 23:09:57 +0200
Subject: [PATCH 3/3] feat(logic): tabele pomocnicze horoskopu (LOG-23)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Komplet wyliczen, ktore astrolog czyta „obok" pozycji:
- bilans zywiolow i jakosci w czterech wariantach (7 klasycznych / 10 z nowozytnymi,
z Ascendentem i bez) + wykrywanie BRAKUJACYCH zywiolow — klasyczne „no air",
podstawa pod scoring sily (LOG-21),
- faza Ksiezyca: elongacja, nazwa fazy, procent oswietlenia, przybywa/ubywa,
- stopnie krytyczne wg jakosci znaku (kardynalne 0/13/26, stale 8/21, zmienne
4/17) + 29 stopien anaretyczny i 0 stopni wejscia w znak,
- dzien i godziny planetarne w porzadku chaldejskim,
- syzygia prenatalna (ostatni now albo pelnia przed urodzeniem),
- podzialy: dwunastniki (D12) i nawamsa (D9).
Dwie rzeczy wymagaly prawdziwego liczenia, nie tabelki:
* godziny planetarne sa NIEROWNE — dzien od wschodu do zachodu dzieli sie na 12,
noc osobno. Bez faktycznego wschodu/zachodu wynik bylby zmyslony, wiec szukamy
ich numerycznie (przejscie wysokosci Slonca przez -0°50', bisekcja jak przy
stacjach z LOG-03). Doba planetarna startuje o WSCHODZIE, nie o polnocy.
* syzygia prenatalna — szukanie wstecz przejscia elongacji przez 0/180 stopni.
Walidacja wobec faktow NIEZALEZNYCH od naszego kodu:
- 30.04.1984 to poniedzialek -> wladca dnia Ksiezyc; 5. godzina poniedzialku
w porzadku chaldejskim to Slonce (Mo, Sa, Ju, Ma, Su) — zgadza sie,
- wschod/zachod dla Krakowa: 03:18 / 17:57 UTC = 5:18 / 19:57 lokalnie — zgodne
z rzeczywistoscia dla konca kwietnia,
- syzygia: pelnia 15.04.1984 19:10:45 UTC; rzeczywista byla 19:11 — roznica
ponizej minuty,
- bilans przeliczony recznie: Ogien 4, Ziemia 4, Woda 3, Powietrze 0.
UI: checkbox „tabele dodatkowe" na ekranie Horoskop (opt-in, bo szuka numerycznie)
i sekcja wynikow. Endpoint: /chart/positions?tables=true.
Testy: 28 nowych (w tym noc polarna -> brak godzin planetarnych, oraz sprawdzenie,
ze w znalezionej syzygii elongacja FAKTYCZNIE wynosi 0/180). Calosc: 202 passed /
1 skipped + 17 (prezentacja). Zweryfikowane e2e w UI.
Co-Authored-By: Claude Opus 4.8
---
services/logic/app/engine/tables.py | 368 ++++++++++++++++++
services/logic/app/main.py | 5 +
services/logic/tests/test_tables.py | 216 ++++++++++
.../presentation/app/clients/logic_client.py | 4 +-
services/presentation/app/main.py | 5 +-
.../presentation/app/templates/chart.html | 83 ++++
6 files changed, 678 insertions(+), 3 deletions(-)
create mode 100644 services/logic/app/engine/tables.py
create mode 100644 services/logic/tests/test_tables.py
diff --git a/services/logic/app/engine/tables.py b/services/logic/app/engine/tables.py
new file mode 100644
index 0000000..4420956
--- /dev/null
+++ b/services/logic/app/engine/tables.py
@@ -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
diff --git a/services/logic/app/main.py b/services/logic/app/main.py
index 04824cc..c19f57b 100644
--- a/services/logic/app/main.py
+++ b/services/logic/app/main.py
@@ -41,6 +41,7 @@ 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
@@ -72,6 +73,10 @@ 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
diff --git a/services/logic/tests/test_tables.py b/services/logic/tests/test_tables.py
new file mode 100644
index 0000000..05ddbe8
--- /dev/null
+++ b/services/logic/tests/test_tables.py
@@ -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
diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py
index 9e5ed1e..f92e854 100644
--- a/services/presentation/app/clients/logic_client.py
+++ b/services/presentation/app/clients/logic_client.py
@@ -39,6 +39,7 @@ 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 = {
@@ -49,9 +50,10 @@ 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 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.raise_for_status()
return r.json()
diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index 3243491..3e13ffd 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -79,17 +79,18 @@ 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}
+ "zodiac": zodiac, "tables": tables}
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,
+ house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html
index d281a51..d73c80f 100644
--- a/services/presentation/app/templates/chart.html
+++ b/services/presentation/app/templates/chart.html
@@ -47,6 +47,8 @@
licz stacje planet (wolniejsze)
+
+ tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)
{% if location_label %}Wstępnie wpisano lokalizację: {{ location_label }} ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".
{% endif %}
@@ -142,6 +144,87 @@
{% endif %}
+ {% if result.tables %}
+ {% set tb = result.tables %}
+
Tabele dodatkowe (LOG-23)
+
+ {% set bal = tb.tally.with_modern_10_plus_asc %}
+
+ Bilans (10 planet + Asc) Rozkład
+
+ Żywioły
+ {% for e, n in bal.elements.items() %}
+ {{ tb.tally.labels.elements[e] }}: {{ n }}
+ {% endfor %}
+
+ Jakości
+ {% for q, n in bal.qualities.items() %}
+ {{ tb.tally.labels.qualities[q] }}: {{ n }}
+ {% endfor %}
+
+
+
+
+ {% if tb.tally.missing_elements or tb.tally.missing_qualities %}
+
Brak:
+ {% for e in tb.tally.missing_elements %}{{ tb.tally.labels.elements[e] }} {{ ", " if not loop.last }}{% endfor %}
+ {% for q in tb.tally.missing_qualities %}{{ tb.tally.labels.qualities[q] }} {{ ", " if not loop.last }}{% endfor %}
+
+ {% endif %}
+
+ {% if tb.moon_phase %}
+
+ Faza Księżyca: {{ 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' }}
+
+ {% endif %}
+
+ {% if tb.planetary_hours %}
+ {% set ph = tb.planetary_hours %}
+
+ Godziny planetarne: władca dnia {{ ph.day_ruler }} ·
+ {{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
+ godzina trwa {{ ph.hour_length_minutes }} min
+
+ {% endif %}
+
+ {% if tb.prenatal_syzygy %}
+ {% set s = tb.prenatal_syzygy %}
+
+ Syzygia prenatalna: {{ s.type_pl }} {{ s.when_utc }}
+ ({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
+
+ {% endif %}
+
+ {% if tb.critical_degrees %}
+
Stopnie krytyczne
+
+ Obiekt Pozycja Uwaga
+
+ {% for c in tb.critical_degrees %}
+ {{ c.name }} {{ c.in_sign }}
+ {{ c.flags | join('; ') }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+ Podziały: dwunastniki (D12) i nawamsa (D9)
+
+ Obiekt D12 D9
+
+ {% for d in tb.divisional %}
+ {{ d.name }} {{ d.d12_in_sign }}
+ {{ d.d9_in_sign }}
+ {% endfor %}
+
+
+
+ {% endif %}
+
{% if result.cusps %}
Cusps domów ({{ result.house_system }})