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 = + ''; + 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 %} -
-
- -
- -

- 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 %} - -
- - 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 %} +
+
+ +
+ +

+ 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 %} + +
+ + 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 @@
+
{% 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 %} + + + + + {% for e, n in bal.elements.items() %} + + {% endfor %} + + + {% for q, n in bal.qualities.items() %} + + {% endfor %} + + + +
Bilans (10 planet + Asc)Rozkład
Żywioły{{ tb.tally.labels.elements[e] }}: {{ n }}
Jakości{{ tb.tally.labels.qualities[q] }}: {{ n }}
+ {% 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
+ + + + {% for c in tb.critical_degrees %} + + + {% endfor %} + +
ObiektPozycjaUwaga
{{ c.name }}{{ c.in_sign }}{{ c.flags | join('; ') }}
+ {% endif %} + +
+ Podziały: dwunastniki (D12) i nawamsa (D9) + + + + {% for d in tb.divisional %} + + + {% endfor %} + +
ObiektD12D9
{{ d.name }}{{ d.d12_in_sign }}{{ d.d9_in_sign }}
+
+ {% endif %} + {% if result.cusps %}
Cusps domów ({{ result.house_system }})