114b7eebdf
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
build / build (push) Successful in 1m49s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m20s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m0s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 37s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
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>
71 lines
2.5 KiB
Python
71 lines
2.5 KiB
Python
"""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"
|