Merge origin/master do PRE-16: naprawa regresji strumienia postepu
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m33s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 15s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m39s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m45s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 26s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 15s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m33s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 15s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m39s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m45s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 26s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 15s
Konflikt w logic_client.py::positions() rozwiazany biorac OBIE zmiany: routing przez szyfrowane _post() (PRE-16) + dluzszy timeout takze dla tables (LOG-23) — warunek (stations or tables). Wazniejsza rzecz, ktora scalenie ujawnilo: okno postepu (#19/#22) i szyfrowanie lacz (#21) powstaly na rownoleglych galeziach, ktore sie nie widzialy. Po zejsciu razem strumien horoskopu szedl SUROWYM httpx, z pominieciem szyfrowania. Przy wlaczonym LINK_ENCRYPTION_REQUIRED serwer odrzucalby to zadanie (400), a nawet bez wymagania odpowiedz wracalaby jako nieczytelne ramki — okno postepu przestaloby dzialac na produkcji. Naprawa: - nowy link_crypto.stream_lines(): strumieniowe POST przez szyfrowane lacze; pieczetuje zadanie i odszyfrowuje odpowiedz ramka po ramce, sklejajac bufor bo granice ramek nie pokrywaja sie z granicami linii NDJSON. Dostarczanie na zywo zachowane. Bez klucza — jak dotad (dev). - horoscope_stream() w kliencie idzie teraz przez stream_lines zamiast surowego client.stream. - fail-closed takze dla strumienia: bez klucza przy wymaganym szyfrowaniu klient nie wysyla NIC (wczesniej cialo — dane urodzenia — szloby w eter, dopiero potem serwer odmawial). Ujednolica kontrakt z call(). Weryfikacja e2e na prawdziwym uvicornie z podsluchem gniazda: z kluczem strumien dziala (5 etapow + result, na zywo), na kablu ZERO tresci bazy (grep=0; jedyne 'horoscope' to sciezka URL w naglowku, ktory z zalozenia jest jawny); bez klucza klient zatrzymuje sie przed wyslaniem. Testy: +4 na stream_lines (round-trip, sciezka jawna, fail-closed serwera i klienta), niezmiennik strukturalny rozszerzony o stream_lines jako droge w dol (sprawdzone celowym zepsuciem — czerwienieje). Calosc: logika 234 passed / 1 skipped, prezentacja 25 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ class LogicClient:
|
||||
house_system: str = "whole_sign",
|
||||
stations: bool = False,
|
||||
zodiac: str = "tropical",
|
||||
tables: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||
payload = {
|
||||
@@ -63,9 +64,10 @@ class LogicClient:
|
||||
"house_system": house_system,
|
||||
"stations": stations,
|
||||
"zodiac": zodiac,
|
||||
"tables": tables,
|
||||
}
|
||||
# stacje wymagają root-findów — dłuższy timeout
|
||||
timeout = max(settings.http_timeout, 60.0) if stations else settings.http_timeout
|
||||
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
|
||||
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
||||
return self._post("/chart/positions", payload, timeout)
|
||||
|
||||
def report(
|
||||
@@ -114,6 +116,22 @@ class LogicClient:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
|
||||
|
||||
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.
|
||||
|
||||
Idzie przez szyfrowane łącze jak reszta ruchu w dół (PRE-16). Bez tego przy
|
||||
włączonym `LINK_ENCRYPTION_REQUIRED` serwer odrzuciłby nieszyfrowane żądanie
|
||||
i okno postępu przestałoby działać. `stream_lines` pieczętuje żądanie i
|
||||
odszyfrowuje odpowiedź ramka po ramce, zachowując dostarczanie na żywo.
|
||||
"""
|
||||
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
||||
yield from link_crypto.stream_lines(
|
||||
client, f"{self.base_url}/chart/horoscope/stream",
|
||||
payload=payload, headers=_auth_headers(), link=_link())
|
||||
|
||||
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:
|
||||
|
||||
@@ -464,3 +464,58 @@ def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||
seq += 1
|
||||
if buffer:
|
||||
raise LinkError("strumień urwał się w połowie ramki")
|
||||
|
||||
|
||||
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||
link: Link | None) -> Iterator[str]:
|
||||
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||
|
||||
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||
dopiero na znakach nowej linii.
|
||||
|
||||
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||
działał bez zmian.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
import httpx as _httpx
|
||||
|
||||
request_headers = dict(headers or {})
|
||||
if link is None:
|
||||
if encryption_required():
|
||||
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||
raise LinkError(
|
||||
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||
)
|
||||
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||
response.raise_for_status()
|
||||
for text_line in response.iter_lines():
|
||||
if text_line:
|
||||
yield text_line
|
||||
return
|
||||
|
||||
path = _httpx.URL(url).path
|
||||
stamp = stamp_now()
|
||||
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||
response.raise_for_status()
|
||||
buffer = bytearray()
|
||||
for plain in open_response_stream(response, link):
|
||||
buffer += plain
|
||||
while True:
|
||||
nl = buffer.find(b"\n")
|
||||
if nl < 0:
|
||||
break
|
||||
text_line = bytes(buffer[:nl])
|
||||
del buffer[:nl + 1]
|
||||
if text_line:
|
||||
yield text_line.decode("utf-8")
|
||||
if buffer:
|
||||
yield bytes(buffer).decode("utf-8")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,17 +79,18 @@ def chart_compute(
|
||||
house_system: str = Form("whole_sign"),
|
||||
stations: bool = Form(False),
|
||||
zodiac: str = Form("tropical"),
|
||||
tables: bool = Form(False),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
||||
"zodiac": zodiac}
|
||||
"zodiac": zodiac, "tables": tables}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=house_system, stations=stations, zodiac=zodiac,
|
||||
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
||||
)
|
||||
except (httpx.HTTPError,) as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
@@ -228,6 +230,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,31 @@ 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 */
|
||||
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
|
||||
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
|
||||
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
|
||||
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
|
||||
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1200; padding: 1rem; }
|
||||
.progress-overlay[hidden] { display: none; }
|
||||
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
||||
.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 %}
|
||||
@@ -47,6 +47,8 @@
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||
licz stacje planet (wolniejsze)</label>
|
||||
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
|
||||
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
|
||||
</div>
|
||||
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||
<div class="actions">
|
||||
@@ -142,6 +144,87 @@
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.tables %}
|
||||
{% set tb = result.tables %}
|
||||
<div class="meta">Tabele dodatkowe (LOG-23)</div>
|
||||
|
||||
{% set bal = tb.tally.with_modern_10_plus_asc %}
|
||||
<table class="angles">
|
||||
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Żywioły</td>
|
||||
{% for e, n in bal.elements.items() %}
|
||||
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr><td>Jakości</td>
|
||||
{% for q, n in bal.qualities.items() %}
|
||||
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
|
||||
{% endfor %}
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
|
||||
<p class="muted small">Brak:
|
||||
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if tb.moon_phase %}
|
||||
<p class="muted small">
|
||||
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
|
||||
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
|
||||
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
|
||||
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if tb.planetary_hours %}
|
||||
{% set ph = tb.planetary_hours %}
|
||||
<p class="muted small">
|
||||
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
|
||||
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
|
||||
godzina trwa {{ ph.hour_length_minutes }} min
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if tb.prenatal_syzygy %}
|
||||
{% set s = tb.prenatal_syzygy %}
|
||||
<p class="muted small">
|
||||
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
|
||||
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if tb.critical_degrees %}
|
||||
<div class="meta">Stopnie krytyczne</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in tb.critical_degrees %}
|
||||
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
|
||||
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<details class="loc">
|
||||
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
|
||||
<table>
|
||||
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in tb.divisional %}
|
||||
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
|
||||
<td class="mono">{{ d.d9_in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if result.cusps %}
|
||||
<details class="loc">
|
||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user