Compare commits

..

4 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:58:56 +02:00
gitea 163ace4283 feat(ui): interaktywny wybor modelu u dostawcy
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m14s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 10m19s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 46s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 27s
build / build (push) Successful in 1m18s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m45s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m2s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 23s
Uzytkownik wybiera nie tylko dostawce, ale konkretny model — Fable czy Opus
u Anthropica, gpt-4o-mini czy gpt-5 u OpenAI, cokolwiek ma pobrane lokalnie.

- app/llm/catalog.py: podpowiedzi modeli per dostawca wraz z oknem kontekstu,
  nadpisywalne przez <DOSTAWCA>_MODELS; GET /llm/models wystawia je dla UI.
- Pole modelu w UI jest TEKSTOWE z datalista, nie zamknietym <select> — konto
  moze miec dostep do modeli, o ktorych kod nie wie, a nowe wychodza szybciej,
  niz aktualizuje sie katalog. Puste pole = model domyslny dostawcy.
- static/models.js: zmiana dostawcy przelacza podpowiedzi, podmienia placeholder
  na model domyslny i pokazuje okno kontekstu wybranego modelu.
- build_provider(name, model) — model z zadania wygrywa nad konfiguracja.

WAZNE (znalezione przy tescie e2e): liczenie budzetu „maksymalny kontekst modelu"
szlo przez build_provider(), ktory WYMAGA klucza API — bez klucza budzet cicho
spadal do wartosci zapasowej i byl identyczny dla wszystkich modeli Anthropic.
Budzet zalezy wylacznie od okna kontekstu, wiec doszlo resolve_model(), ktore
rozwiazuje nazwe modelu bez budowania dostawcy. Teraz budzet realnie sie rozni:
Opus/Fable 3,48 mln znakow, Haiku 536 tys., gpt-4o-mini 438 tys., llama3.1 8 tys.

Pewnosc danych w katalogu: modele Anthropic pochodza z oficjalnej dokumentacji
API (okna i limity zgodne z limits.py); modele OpenAI to podpowiedzi, ktorych
nie weryfikowalem; lokalne zaleza od tego, co masz pobrane.

Testy: 174 passed / 1 skipped (logika) + 17 (prezentacja). Nowy test strukturalny
pilnuje, ze KAZDE wywolanie w dol niesie wybrany model i dostawce — dokladnie ta
klasa bledu zlapala brakujacy parametr przy horoskopie okresowym.
Zweryfikowane w przegladarce: przelaczanie dostawcy podmienia liste modeli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:40:19 +02:00
gitea f33cdc88f4 feat(llm): horoskop powstaje zawsze — kontynuacja, okna kontekstu, budzet max
PRZYCZYNA PUSTYCH ODPOWIEDZI NA ANTHROPICU (potwierdzona w dokumentacji API):
domyslnym modelem byl `claude-sonnet-5`, ktory przy POMINIETYM parametrze
`thinking` wlacza myslenie adaptacyjne, a `thinking.display` domyslnie jest
"omitted". Tokeny myslenia licza sie do max_tokens, wiec przy LLM_MAX_TOKENS=2000
cala tura wychodzila jako bloki `thinking` z pustym tekstem — parser filtrowal
type=="text" i zwracal pusty string. Opus 4.8 bez `thinking` nie mysli, wiec tam
objaw by nie wystapil.

Gwarancja niepustej odpowiedzi (wszyscy trzej dostawcy):
- generate() to teraz PETLA, nie pojedynczy strzal: tura -> jesli urwana na
  limicie, dopisz ture „kontynuuj" w tej samej rozmowie i sklej tekst,
- tura zlozona z samego myslenia traktowana jak urwana (nie jak pustka),
- pusta i NIE urwana -> jedna proba z podpowiedzia, dopiero potem blad,
- kontynuacja konczy sie tura UZYTKOWNIKA — Claude odrzuca prefill asystenta (400),
- `thinking` konfigurowany JAWNIE (adaptive + effort=high; ANTHROPIC_THINKING=off).

Okna kontekstu i rezerwa na odpowiedz (app/llm/limits.py):
- tabela okien/limitow wyjscia per model + nadpisanie z ENV,
- plan() liczy okno odpowiedzi jako okno - prompt - margines i NIGDY nie oddaje
  calego kontekstu promptowi,
- Anthropic liczy tokeny DOKLADNIE (/v1/messages/count_tokens), reszta szacuje,
- >90 tys. tokenow promptu -> ostrzezenie, ale wyslanie NADAL mozliwe i z pelnym
  oknem odpowiedzi.

UI: suwak budzetu rozszerzony o „bardzo obszerny" i „maksymalny kontekst modelu"
(liczony z okna wybranego modelu po odjeciu rezerwy); przy wyniku widac plan
tokenow, liczbe tur i ostrzezenia.

Domyslny model Anthropic: claude-opus-4-8.

Testy: 170 passed / 1 skipped (logika) + 15 (prezentacja). Nowe testy pokrywaja
sklejanie kontynuacji, brak prefillu asystenta, ture z samego myslenia, rezerwe
na odpowiedz i prog ostrzezenia. Zweryfikowane e2e na atrapie Anthropica
odtwarzajacej zgloszony objaw: 3 tury, obie czesci tekstu obecne.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:40:19 +02:00
gitea 877ec91ff0 fix(llm): pusta odpowiedz modelu to blad, nie pusta strona
build / build (push) Successful in 57s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 13m7s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m57s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 40s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
Objaw zgloszony przez uzytkownika: w Kalendarzu horoskop wyswietla sie
poprawnie, w Interpretacjach zapytanie wychodzi, wraca — i NIC sie nie
pokazuje. Bez zadnego komunikatu.

Przyczyna: model potrafi oddac pusta tresc (finish_reason=length,
completion_tokens=0), a generate() zwracalo wtedy pusty tekst BEZ bledu.
Widok sprawdza {% if prompt_result.horoscope %} -> falsz -> nie renderuje nic,
a llm_error nie jest ustawiony -> zero wyjasnienia. Cicha awaria.

Asymetria miedzy ekranami wynika z rozmiaru promptu: natalny (13 obiektow x
fasety x opisy z bazy) wypelnia okno kontekstu modelu lokalnego i na odpowiedz
nie zostaje miejsca; okresowy jest mniejszy i sie miesci.

- wspolny straznik _require_text() dla obu dostawcow: pusta lub bialoznakowa
  odpowiedz podnosi LLMError,
- komunikat PROWADZI DO PRZYCZYNY: podaje finish_reason i zuzycie tokenow oraz
  radzi zmniejszyc budzet promptu / zwiekszyc num_ctx / LLM_MAX_TOKENS,
- Anthropic sprowadzony do wspolnego ksztaltu diagnostyki (input/output_tokens).

Dzieki temu uzytkownik widzi powod ORAZ gotowy prompt do recznego uzycia.

Zweryfikowane na zywym stosie z atrapa modelu oddajaca pusta tresc: zamiast
pustej strony pojawia sie pelny komunikat z diagnostyka. Testy: 148 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:05:20 +00:00
12 changed files with 508 additions and 80 deletions
+31 -2
View File
@@ -124,7 +124,14 @@ class _Driver:
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
raise NotImplementedError
def generate(self, prompt: str, max_tokens: int) -> Completion:
def generate(self, prompt: str, max_tokens: int, on_event=None) -> Completion:
"""`on_event(dict)` dostaje zdarzenia postępu — UI pokazuje z nich log.
Raportujemy KAŻDĄ turę, bo to ona trwa; bez tego pasek postępu byłby
ozdobnikiem, a nie informacją."""
def emit(kind: str, message: str, **extra):
if on_event:
on_event({"type": kind, "message": message, **extra})
messages: list[dict] = [{"role": "user", "content": prompt}]
parts: list[str] = []
usage: dict = {}
@@ -137,9 +144,29 @@ class _Driver:
while turns <= MAX_CONTINUATIONS:
turns += 1
budget = max(256, min(remaining, TURN_TOKENS_CAP))
emit("turn_start",
f"Tura {turns}: wysyłam do modelu {self.model} (limit {budget} tokenów)…",
turn=turns)
started = time.monotonic()
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
took = time.monotonic() - started
_merge_usage(usage, turn_usage)
remaining -= budget
# Odejmujemy tokeny FAKTYCZNIE wyprodukowane, nie zamówiony limit tury.
# Inaczej pierwsza tura zjadałaby cały budżet i urwana odpowiedź nigdy
# nie doczekałaby się kontynuacji — wracałby do użytkownika fragment
# udający całość.
produced = (turn_usage or {}).get("completion_tokens")
if produced is None:
produced = (turn_usage or {}).get("output_tokens")
if produced is None:
produced = max(1, int(len(text) / 3.6))
remaining -= max(1, int(produced))
emit("turn_end",
f"Tura {turns}: odebrano {len(text.strip())} znaków w {took:.1f}s"
+ (" — odpowiedź urwana, poproszę o dokończenie" if truncated else ""),
turn=turns, chars=len(text.strip()), truncated=truncated)
chunk = text.strip()
if chunk:
@@ -172,6 +199,8 @@ class _Driver:
final = _join(parts)
if not final:
raise LLMError(_explain_empty(turns, usage, stop))
emit("generated", f"Gotowe: {len(final)} znaków w {turns} turach.",
chars=len(final), turns=turns)
usage["turns"] = turns
return Completion(text=final, model=model_name, provider=self.name,
+66
View File
@@ -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.
+70
View File
@@ -0,0 +1,70 @@
"""Strumień postępu długiej operacji (NDJSON).
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
zwykłym `fetch()` i jest trywialny do sparsowania.
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
"""
from __future__ import annotations
import json
import queue
import threading
import traceback
from collections.abc import Iterator
from typing import Any, Callable
_HEARTBEAT_SECONDS = 10.0
_DONE = object()
def line(kind: str, message: str, **extra: Any) -> str:
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
— połączenie nigdy nie urywa się bez wyjaśnienia.
"""
events: queue.Queue = queue.Queue()
def emit(event: dict) -> None:
events.put(event)
def run() -> None:
try:
result = work(emit)
events.put({"type": "result", "message": "Gotowe.", "result": result})
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
events.put({
"type": "error",
"message": f"{type(e).__name__}: {e}",
"detail": traceback.format_exc(limit=3),
})
finally:
events.put(_DONE)
worker = threading.Thread(target=run, daemon=True)
worker.start()
while True:
try:
event = events.get(timeout=_HEARTBEAT_SECONDS)
except queue.Empty:
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
# (proxy, load balancer) nie uznały połączenia za martwe
yield line("ping", "")
continue
if event is _DONE:
break
yield json.dumps(event, ensure_ascii=False) + "\n"
+30
View File
@@ -405,3 +405,33 @@ def test_max_budget_differs_between_models(monkeypatch):
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
assert opus > haiku > local > 0
def test_turn_budget_counts_produced_not_requested(monkeypatch):
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
tokenow konczylo petle po jednej turze — urwany fragment wracal jako calosc."""
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
_chat("Zakonczenie. KONIEC", "stop")]
def handler(request):
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
def test_generate_reports_progress_events(monkeypatch):
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
monkeypatch.setattr(httpx, "Client", _mock_client(
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
events = []
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
"p", 8000, on_event=events.append)
kinds = [e["type"] for e in events]
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
assert kinds[-1] == "generated"
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
@@ -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 %}