feat(ui): okno postepu z logiem podczas pisania horoskopu
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m52s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 38s
Testy / Kontrola składni wszystkich warstw (push) Successful in 24s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m50s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 34s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s

Generowanie trwa minutami, a zwykly POST nie dawal zadnego sygnalu — aplikacja
wygladala na zawieszona. Teraz w trakcie pracy pojawia sie okno z logiem,
zegarem i spinnerem.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 22:58:56 +02:00
parent 163ace4283
commit 114b7eebdf
12 changed files with 508 additions and 80 deletions
@@ -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:
+57 -1
View File
@@ -9,11 +9,12 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -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")):
@@ -0,0 +1,133 @@
// Okno postępu przy pisaniu horoskopu.
//
// Problem: generowanie trwa minutami, a zwykły POST formularza nie daje żadnego
// sygnału — aplikacja wygląda na zawieszoną. Zamiast udawanego paska postępu
// czytamy strumień RZECZYWISTYCH zdarzeń z serwera (NDJSON) i wypisujemy je
// jako log: budowa promptu, limity modelu, każda tura generowania.
//
// Degradacja: jeśli przeglądarka nie umie strumieniować `fetch`, nie przechwytujemy
// wysyłki — formularz idzie klasycznie i wszystko działa jak wcześniej, tylko bez okna.
document.addEventListener('DOMContentLoaded', function () {
const canStream = typeof fetch === 'function' && typeof ReadableStream === 'function' &&
typeof TextDecoder === 'function';
const form = document.querySelector('form[action="/interpret"], form[action="/timeline"]');
if (!canStream || !form) return;
const profile = form.getAttribute('action') === '/timeline' ? 'period' : 'natal';
const btn = form.querySelector('button[value="horoscope"]');
if (!btn) return;
// --- okno ---------------------------------------------------------------
const overlay = document.createElement('div');
overlay.className = 'progress-overlay';
overlay.hidden = true;
overlay.innerHTML =
'<div class="progress-box" role="dialog" aria-modal="true" aria-label="Postęp generowania">' +
'<div class="progress-head">' +
'<span class="progress-spinner" aria-hidden="true"></span>' +
'<strong id="progressTitle">Piszę horoskop…</strong>' +
'<span class="progress-clock" id="progressClock">0:00</span>' +
'</div>' +
'<ol class="progress-log" id="progressLog"></ol>' +
'<p class="muted small">Nie zamykaj tej karty — generowanie trwa na serwerze.</p>' +
'<div class="actions"><button type="button" class="ghost" id="progressClose" hidden>Zamknij</button></div>' +
'</div>';
document.body.appendChild(overlay);
const logEl = overlay.querySelector('#progressLog');
const clockEl = overlay.querySelector('#progressClock');
const titleEl = overlay.querySelector('#progressTitle');
const closeEl = overlay.querySelector('#progressClose');
let timer = null;
function addLine(text, kind) {
const li = document.createElement('li');
if (kind) li.className = 'log-' + kind;
const now = new Date();
li.textContent = String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0') + ' ' + text;
logEl.appendChild(li);
logEl.scrollTop = logEl.scrollHeight;
}
function startClock() {
const t0 = Date.now();
timer = setInterval(function () {
const s = Math.floor((Date.now() - t0) / 1000);
clockEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}, 1000);
}
function finish(title, allowClose) {
if (timer) { clearInterval(timer); timer = null; }
titleEl.textContent = title;
overlay.querySelector('.progress-spinner').style.visibility = 'hidden';
if (allowClose) closeEl.hidden = false;
}
closeEl.addEventListener('click', function () { overlay.hidden = true; });
// --- przechwycenie wysyłki ----------------------------------------------
btn.addEventListener('click', function (event) {
event.preventDefault();
if (!form.reportValidity()) return; // te same reguły co przy zwykłej wysyłce
const data = new FormData(form);
data.set('profile', profile);
logEl.innerHTML = '';
closeEl.hidden = true;
overlay.querySelector('.progress-spinner').style.visibility = '';
titleEl.textContent = 'Piszę horoskop…';
overlay.hidden = false;
startClock();
addLine('Wysyłam żądanie…');
fetch('/horoscope/stream', { method: 'POST', body: data })
.then(function (response) {
if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
function pump() {
return reader.read().then(function (chunk) {
if (chunk.done) return;
buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // ostatni może być niepełny
lines.forEach(function (raw) {
if (!raw.trim()) return;
let ev;
try { ev = JSON.parse(raw); } catch (e) { return; }
if (ev.type === 'ping') return; // sam heartbeat, nie logujemy
if (ev.type === 'result') {
addLine(ev.message || 'Gotowe.', 'ok');
if (ev.html) {
const host = document.getElementById('promptResult');
if (host) host.innerHTML = ev.html;
}
finish('Gotowe', true);
overlay.hidden = true;
const host = document.getElementById('promptResult');
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
: ev.type === 'warn' ? 'warn' : null);
if (ev.type === 'error') finish('Nie udało się', true);
});
return pump();
});
}
return pump();
})
.catch(function (e) {
addLine('Połączenie przerwane: ' + e.message, 'err');
addLine('Możesz spróbować ponownie — nic nie zostało utracone.');
finish('Nie udało się', true);
});
});
});
@@ -88,3 +88,27 @@ textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizi
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
/* Okno postępu przy generowaniu horoskopu */
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
display: flex; align-items: center; justify-content: center;
z-index: 50; padding: 1rem; }
.progress-overlay[hidden] { display: none; }
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
color: var(--muted); font-size: .9rem; }
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
border: 2px solid var(--line); border-top-color: var(--accent);
animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
max-height: 15rem; overflow-y: auto;
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
.progress-log li { color: var(--ink); white-space: pre-wrap; }
.progress-log li.log-ok { color: #7fd18b; }
.progress-log li.log-warn { color: #e0c060; }
.progress-log li.log-err { color: #ef6b6b; }
@@ -36,80 +36,4 @@
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
</p>
{% if prompt_result %}
{% set st = prompt_result.stats %}
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
{% if prompt_result.horoscope %}
<div class="meta">
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
model: {{ prompt_result.model }}
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
{% endif %}
{% if prompt_result.leaves_lan %}
· <strong class="retro">dane opuściły sieć</strong>
{% else %}
· dane nie opuściły sieci
{% endif %}
</div>
<div class="actions">
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
</div>
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
<p class="muted small">
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
</p>
{% endif %}
{% if prompt_result.warnings %}
{% for w in prompt_result.warnings %}
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
{% endfor %}
{% endif %}
{% if prompt_result.token_plan %}
{% set tp = prompt_result.token_plan %}
<p class="muted small">
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
zarezerwowane na odpowiedź {{ tp.max_output }}
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
{% endif %}
</p>
{% endif %}
{% if prompt_result.llm_error %}
<div class="error">
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
</div>
{% endif %}
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
<div class="meta">
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
budżet: {{ st.budget }} ·
wskazań: <strong>{{ st.included }}</strong>
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
</div>
{% if st.omitted %}
<p class="muted small">
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
</p>
{% endif %}
{% if prompt_result.data_error %}
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
{% endif %}
<div class="actions">
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
</div>
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
{% endif %}
<div id="promptResult">{% include "_prompt_result.html" %}</div>
@@ -0,0 +1,80 @@
{# Blok WYNIKU generowania (prompt/horoskop). Wydzielony, bo wstawia go także
okno postępu po zakończeniu strumienia — dzięki temu jest jedno źródło
prawdy dla wyglądu wyniku, niezależnie od drogi, którą przyszedł. #}
{% if prompt_result %}
{% set st = prompt_result.stats %}
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
{% if prompt_result.horoscope %}
<div class="meta">
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
model: {{ prompt_result.model }}
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
{% endif %}
{% if prompt_result.leaves_lan %}
· <strong class="retro">dane opuściły sieć</strong>
{% else %}
· dane nie opuściły sieci
{% endif %}
</div>
<div class="actions">
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
</div>
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
<p class="muted small">
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
</p>
{% endif %}
{% if prompt_result.warnings %}
{% for w in prompt_result.warnings %}
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
{% endfor %}
{% endif %}
{% if prompt_result.token_plan %}
{% set tp = prompt_result.token_plan %}
<p class="muted small">
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
zarezerwowane na odpowiedź {{ tp.max_output }}
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
{% endif %}
</p>
{% endif %}
{% if prompt_result.llm_error %}
<div class="error">
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
</div>
{% endif %}
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
<div class="meta">
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
budżet: {{ st.budget }} ·
wskazań: <strong>{{ st.included }}</strong>
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
</div>
{% if st.omitted %}
<p class="muted small">
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
</p>
{% endif %}
{% if prompt_result.data_error %}
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
{% endif %}
<div class="actions">
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
</div>
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
{% endif %}
@@ -91,4 +91,5 @@
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
<script src="/static/progress.js"></script>
{% endblock %}
@@ -81,4 +81,5 @@
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
<script src="/static/progress.js"></script>
{% endblock %}