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
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:
@@ -124,7 +124,14 @@ class _Driver:
|
|||||||
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
||||||
raise NotImplementedError
|
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}]
|
messages: list[dict] = [{"role": "user", "content": prompt}]
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
usage: dict = {}
|
usage: dict = {}
|
||||||
@@ -137,9 +144,29 @@ class _Driver:
|
|||||||
while turns <= MAX_CONTINUATIONS:
|
while turns <= MAX_CONTINUATIONS:
|
||||||
turns += 1
|
turns += 1
|
||||||
budget = max(256, min(remaining, TURN_TOKENS_CAP))
|
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)
|
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
||||||
|
took = time.monotonic() - started
|
||||||
_merge_usage(usage, turn_usage)
|
_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()
|
chunk = text.strip()
|
||||||
if chunk:
|
if chunk:
|
||||||
@@ -172,6 +199,8 @@ class _Driver:
|
|||||||
final = _join(parts)
|
final = _join(parts)
|
||||||
if not final:
|
if not final:
|
||||||
raise LLMError(_explain_empty(turns, usage, stop))
|
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
|
usage["turns"] = turns
|
||||||
return Completion(text=final, model=model_name, provider=self.name,
|
return Completion(text=final, model=model_name, provider=self.name,
|
||||||
|
|||||||
@@ -265,6 +265,72 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
|
|||||||
return out
|
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")
|
@app.get("/llm/models")
|
||||||
def llm_models() -> dict:
|
def llm_models() -> dict:
|
||||||
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -405,3 +405,33 @@ def test_max_budget_differs_between_models(monkeypatch):
|
|||||||
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
||||||
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
||||||
assert opus > haiku > local > 0
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
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]:
|
def llm_models(self) -> dict[str, Any]:
|
||||||
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
@@ -228,6 +229,61 @@ def timeline_run(
|
|||||||
return templates.TemplateResponse(request, "timeline.html", ctx)
|
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) ----------------
|
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||||||
@app.get("/geocode")
|
@app.get("/geocode")
|
||||||
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
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;
|
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||||
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
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ć.
|
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if prompt_result %}
|
<div id="promptResult">{% include "_prompt_result.html" %}</div>
|
||||||
{% 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 %}
|
|
||||||
|
|||||||
@@ -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/now.js"></script>
|
||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
<script src="/static/models.js"></script>
|
||||||
|
<script src="/static/progress.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -81,4 +81,5 @@
|
|||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
<script src="/static/models.js"></script>
|
||||||
|
<script src="/static/progress.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user