astrololo: generowanie tekstu jako moduł odłączalny (2/5)
build / build (push) Failing after 2s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
build / build (push) Failing after 2s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
Drugi z pięciu kroków budowy trzech produktów. Czysty refaktor — zachowanie
aplikacji się nie zmienia, liczba testów rośnie tylko o nowe.
DLACZEGO WARUNEK W SZABLONIE NIE WYSTARCZA. Dotąd generowanie było chowane przez
`{% if can(request, 'ai') %}`. To sprawia, że funkcji nie WIDAĆ, ale nie że jej
NIE MA: plik szablonu dalej leży w obrazie i dalej zawiera jej nazwy, więc `grep`
po kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie. Astroklient
ma nie mieć śladu, nie mieć wyłączoną funkcję — więc granica musi być KATALOGIEM.
CO SIĘ PRZENIOSŁO do app/dodatki/: dwa szablony, pięć plików statycznych, trasa
strumienia, cztery metody klienta warstwy logicznej, wpis w katalogu funkcji,
wpisy tras i zasobów, akcje formularza i reguły CSS nazywające funkcję
(`textarea.prompt`).
JAK APLIKACJA O TO PYTA. Neutralny most `app/rozszerzenia.py` odpowiada wyłącznie
na pytanie „czy coś jest podpięte i co wnosi". Sam nie wymienia ani jednej nazwy —
pilnuje tego osobny test, bo most jest w KAŻDYM obrazie. Dlatego też katalog
nazywa się `dodatki`, a nie `ai`: nazwa w instrukcji importu byłaby dokładnie tym
śladem, którego wydzielanie ma się pozbyć.
TRZY MIEJSCA, KTÓRE OKAZAŁY SIĘ TRUDNE:
1. Pola formularza. FastAPI czyta je z SYGNATURY, a wspólny handler nie może
wymieniać `prompt_budget` ani `llm_provider`. Rozwiązane zależnością: moduł
deklaruje własne pola u siebie, handler wie tylko, że dostaje słownik.
2. Klient warstwy logicznej jest wspólny, więc metody `prompt`/`horoscope`/
`llm_models` musiały z niego wyjść. Wspólny klient daje teraz samą drogę
w dół (`wywolaj`, `pobierz`, `strumien`) — z szyfrowaniem łącza i tokenem
międzywarstwowym; co nią pojedzie, jest sprawą modułu.
3. Podtytuł ekranu Skompiluj miał wariant „z AI" i wariant bez. Zamiast warunku
jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie.
DOCKERFILE. `COPY . .` wnosiło do obrazu także testy — a plik testowy nazywa
funkcje wprost. Teraz wchodzi wyłącznie `app/`.
TEST GRANICY. `test_ai_tylko_w_module.py` przechodzi po wszystkim poza modułem
i szuka siedemnastu słów. Znalazł dwanaście resztek, których nie widziałem:
komentarze w main.py i security.py, przykład `models.js` w komentarzu features.py,
komentarze w compile.js i w trzech arkuszach, oraz regułę `textarea.prompt`,
która przy rozbijaniu CSS trafiła do arkuszy ekranów zamiast do modułu.
Ma też kontrolę pozytywną (te słowa MAJĄ padać w module — inaczej test
przechodziłby, gdyby funkcję wydrążono) i test odłączalności mostu.
SPRAWDZONE NA KOPII BEZ MODUŁU: aplikacja startuje, wszystkie ekrany oddają 200,
katalog ma 13 funkcji zamiast 14, trasy strumienia nie ma, spreparowane
`action=prompt` wraca do akcji domyślnej bez śladu, a `grep` po drzewie nie
znajduje ani jednego ze słów.
Testy: presentation 362, logic 342, data 37, render 41.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #80.
This commit is contained in:
@@ -40,6 +40,29 @@ class LogicClient:
|
||||
payload=payload, headers=_auth_headers(),
|
||||
link=_link())
|
||||
|
||||
# ── transport dla modułów opcjonalnych ─────────────────────────────────
|
||||
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
|
||||
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
|
||||
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
|
||||
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
|
||||
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
return self._post(sciezka, payload, timeout)
|
||||
|
||||
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
|
||||
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
|
||||
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
|
||||
headers=_auth_headers(), link=_link())
|
||||
|
||||
def strumien(self, sciezka: str, payload: dict[str, Any]):
|
||||
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
|
||||
|
||||
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
|
||||
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
|
||||
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
||||
yield from link_crypto.stream_lines(
|
||||
client, f"{self.base_url}{sciezka}",
|
||||
payload=payload, headers=_auth_headers(), link=_link())
|
||||
|
||||
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||
return self._post("/api/query", payload, settings.http_timeout)
|
||||
@@ -97,61 +120,6 @@ class LogicClient:
|
||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
||||
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
|
||||
|
||||
def prompt(
|
||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
||||
provider: str | None = None, model: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
|
||||
|
||||
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
|
||||
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
|
||||
payload: dict[str, Any] = {
|
||||
"profile": profile, "when_utc": when_utc_iso,
|
||||
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
if from_date and to_date:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
|
||||
|
||||
def horoscope(
|
||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", provider: str | None = None, model: str | None = None,
|
||||
from_date: str | None = None, to_date: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
|
||||
|
||||
Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"profile": profile, "when_utc": when_utc_iso,
|
||||
"lat": lat, "lon": lon, "budget": budget,
|
||||
}
|
||||
if provider:
|
||||
payload["provider"] = provider
|
||||
if model:
|
||||
payload["model"] = model
|
||||
if from_date and to_date:
|
||||
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 bases(self) -> dict[str, Any]:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
@@ -185,12 +153,6 @@ class LogicClient:
|
||||
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||
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:
|
||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
|
||||
headers=_auth_headers(), link=_link())
|
||||
|
||||
def timeline(
|
||||
self, when_utc_iso: str, lat: float, lon: float,
|
||||
from_date: str, to_date: str, interpret: bool = True,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Generowanie tekstu przez model językowy — moduł CAŁKOWICIE ODŁĄCZALNY.
|
||||
|
||||
DLACZEGO OSOBNY KATALOG, A NIE `{% if %}` W SZABLONACH. Warunek w szablonie
|
||||
wystarcza, żeby funkcji nie było WIDAĆ, ale nie żeby jej NIE BYŁO. Plik szablonu
|
||||
i tak leży w obrazie i i tak zawiera słowa opisujące funkcję — `grep` po
|
||||
kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie.
|
||||
|
||||
Astrololo zawiera ten katalog. Astroklient — nie, i to jest jedyna różnica
|
||||
między nimi po stronie funkcji astrologicznych. Dlatego granica MUSI być
|
||||
katalogiem: co jest w środku, znika razem z nim, a co jest na zewnątrz, zostaje.
|
||||
|
||||
ZASADA DLA KOGOŚ, KTO TU DOPISUJE. Poza tym katalogiem nie wolno napisać ani
|
||||
słowa o modelu, prompcie czy dostawcy — nawet w komentarzu, nawet zaprzeczając.
|
||||
Szablony wspólne mają neutralne gniazda (`_dodatki_*`), a plik wstawiany w
|
||||
gniazdo sam sprawdza uprawnienie. Pilnuje tego test `test_ai_tylko_w_module.py`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.dodatki.katalog import AKCJE, FUNKCJA, TRASY, ZASOBY, katalog_dla, obsluz, pola
|
||||
from app.dodatki.trasy import zamontuj
|
||||
|
||||
__all__ = ["AKCJE", "FUNKCJA", "TRASY", "ZASOBY", "katalog_dla", "obsluz", "pola",
|
||||
"zamontuj"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Co moduł wnosi do aplikacji: funkcję, trasy, zasoby, akcje i pola formularza.
|
||||
|
||||
Wszystko, co gdziekolwiek indziej musiałoby paść z nazwy, jest zebrane TUTAJ.
|
||||
Reszta aplikacji zna wyłącznie neutralny most (`app.rozszerzenia`) i pyta go
|
||||
o dane, nigdy o „generowanie" czy „model".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import Form, Request
|
||||
|
||||
from app.dodatki import klient
|
||||
|
||||
# Pozycja w katalogu funkcji — surowa krotka, żeby nie importować `features`
|
||||
# (to zamknęłoby cykl features → most → moduł → features).
|
||||
FUNKCJA: tuple[tuple[str, str, str, str], ...] = (
|
||||
("ai", "Generowanie tekstu przez model",
|
||||
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
|
||||
"Rozszerzenia"),
|
||||
)
|
||||
|
||||
TRASY: dict[tuple[str, str], str] = {("POST", "/horoscope/stream"): "ai"}
|
||||
|
||||
ZASOBY: dict[str, str] = {
|
||||
"x-ai.css": "ai",
|
||||
"models.js": "ai",
|
||||
"progress.js": "ai",
|
||||
"natal.js": "ai",
|
||||
"predictions.js": "ai",
|
||||
}
|
||||
|
||||
# Akcje formularza wnoszone przez moduł: nazwa → wymagane uprawnienie.
|
||||
AKCJE: dict[str, str] = {"prompt": "ai", "horoscope": "ai"}
|
||||
|
||||
# Wstrzykiwane przez `zamontuj` z main.py — moduł nie importuje aplikacji,
|
||||
# bo to byłby cykl, a przy okazji zmusiłoby aplikację do wiedzy o module.
|
||||
_logic: Any = None
|
||||
_szablony: Any = None
|
||||
|
||||
|
||||
def ustaw_zaleznosci(*, logic: Any, templates: Any) -> None:
|
||||
global _logic, _szablony
|
||||
_logic, _szablony = logic, templates
|
||||
|
||||
|
||||
def pola(prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form(""),
|
||||
llm_model: str = Form("")) -> dict:
|
||||
"""Pola formularza, których wspólny handler nie może wymienić w sygnaturze.
|
||||
|
||||
FastAPI czyta pola z sygnatury, więc zadeklarowanie ich we wspólnym handlerze
|
||||
wpisałoby nazwy `llm_provider` i `prompt_budget` w kod, który trafia także do
|
||||
obrazu bez tego modułu. Zależność przenosi deklarację tutaj."""
|
||||
return {"prompt_budget": prompt_budget, "llm_provider": llm_provider,
|
||||
"llm_model": llm_model}
|
||||
|
||||
|
||||
def _katalog() -> dict:
|
||||
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
||||
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
||||
try:
|
||||
return klient.modele(_logic)
|
||||
except httpx.HTTPError:
|
||||
return {"providers": {}, "defaults": {}}
|
||||
|
||||
|
||||
def katalog_dla(request: Request) -> dict:
|
||||
"""Katalog modeli do kontekstu szablonu — pusty, gdy konto nie ma uprawnienia.
|
||||
|
||||
Katalog trafia do strony jako blok JSON, więc bez tego ograniczenia nazwy
|
||||
dostawców, modeli i rozmiary okien kontekstu byłyby w źródle każdej strony,
|
||||
także dla kont, które o modelach nie mają prawa wiedzieć."""
|
||||
perms = getattr(getattr(request.state, "principal", None), "permissions", frozenset())
|
||||
return {"llm_catalog": _katalog() if "ai" in perms else {}}
|
||||
|
||||
|
||||
def obsluz(akcja: str, *, profil: str, iso_utc: str, lat: float, lon: float,
|
||||
dodatki: dict, od: str = "", do: str = "", **_reszta) -> dict | None:
|
||||
"""Wykonanie akcji modułu. None znaczy „to nie moja akcja"."""
|
||||
if akcja not in AKCJE:
|
||||
return None
|
||||
metoda = klient.prompt if akcja == "prompt" else klient.horoscope
|
||||
argumenty = {"logic": _logic, "profile": profil, "when_utc_iso": iso_utc, "lat": lat, "lon": lon,
|
||||
"budget": dodatki.get("prompt_budget", "medium"),
|
||||
"provider": dodatki.get("llm_provider", ""),
|
||||
"model": dodatki.get("llm_model", "")}
|
||||
if profil == "period":
|
||||
argumenty["from_date"], argumenty["to_date"] = od, do
|
||||
return metoda(**argumenty)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Rozmowa z warstwą logiczną w sprawach tego modułu.
|
||||
|
||||
Metody nie siedzą na wspólnym kliencie, tylko tutaj: gdyby siedziały tam, ich
|
||||
nazwy (`prompt`, `horoscope`, `llm_models`) byłyby w pliku, który jedzie do
|
||||
KAŻDEGO obrazu — także tam, gdzie tego modułu nie ma. Wspólny klient daje samą
|
||||
drogę w dół (`wywolaj`, `pobierz`, `strumien`), z szyfrowaniem łącza i tokenem
|
||||
międzywarstwowym; co nią pojedzie, jest sprawą modułu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def prompt(logic: Any, *, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", from_date: str | None = None,
|
||||
to_date: str | None = None, provider: str | None = None,
|
||||
model: str | None = None) -> dict[str, Any]:
|
||||
"""Gotowy prompt z wyliczeń (LOG-29/30).
|
||||
|
||||
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
|
||||
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
|
||||
payload: dict[str, Any] = {
|
||||
"profile": profile, "when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||
"budget": budget, "provider": provider, "model": model,
|
||||
}
|
||||
if from_date and to_date:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
return logic.wywolaj("/chart/prompt", payload, max(settings.http_timeout, 60.0))
|
||||
|
||||
|
||||
def horoscope(logic: Any, *, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", provider: str | None = None,
|
||||
model: str | None = None, from_date: str | None = None,
|
||||
to_date: str | None = None) -> dict[str, Any]:
|
||||
"""Napisany horoskop (LOG-31). Długi timeout, bo pisanie trwa —
|
||||
zwłaszcza na modelu lokalnym."""
|
||||
payload: dict[str, Any] = {
|
||||
"profile": profile, "when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||
"budget": budget,
|
||||
}
|
||||
if provider:
|
||||
payload["provider"] = provider
|
||||
if model:
|
||||
payload["model"] = model
|
||||
if from_date and to_date:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
return logic.wywolaj("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
|
||||
|
||||
|
||||
def strumien(logic: Any, payload: dict[str, Any]):
|
||||
"""Strumień postępu pisania (NDJSON), przekazywany do przeglądarki.
|
||||
|
||||
Strumień niesie heartbeat, więc cisza na łączu nie zostanie wzięta
|
||||
za zerwanie."""
|
||||
yield from logic.strumien("/chart/horoscope/stream", payload)
|
||||
|
||||
|
||||
def modele(logic: Any) -> dict[str, Any]:
|
||||
"""Katalog modeli per dostawca — podpowiedzi do pola wyboru."""
|
||||
return logic.pobierz("/llm/models")
|
||||
+7
@@ -29,3 +29,10 @@
|
||||
.progress-log li.log-ok { color: #7fd18b; }
|
||||
.progress-log li.log-warn { color: #e0c060; }
|
||||
.progress-log li.log-err { color: #ef6b6b; }
|
||||
|
||||
/* Generator promptu do LLM (LOG-29/30) */
|
||||
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||
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; }
|
||||
@@ -0,0 +1,9 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}
|
||||
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
|
||||
każdy sprawdza, czy jego kontener jest na stronie. #}
|
||||
<script src="{{ static('natal.js') }}"></script>
|
||||
<script src="{{ static('predictions.js') }}"></script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}
|
||||
{# Części od modelu — wstawia przeglądarka z magazynu lokalnego. #}
|
||||
<div id="reportNatal"></div>
|
||||
<div id="reportPredictions"></div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}
|
||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}
|
||||
<p class="muted small" id="natalNote"></p>{% endif %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}
|
||||
<script src="{{ static('models.js') }}"></script>
|
||||
<script src="{{ static('progress.js') }}"></script>
|
||||
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
|
||||
<script src="{{ static('natal.js') }}"></script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}
|
||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}
|
||||
<script src="{{ static('models.js') }}"></script>
|
||||
<script src="{{ static('progress.js') }}"></script>
|
||||
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
|
||||
<script src="{{ static('predictions.js') }}"></script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{# Wstawka modułu. Sprawdzenie uprawnienia siedzi TUTAJ, a nie w szablonie
|
||||
wspólnym: tam byłoby nazwą funkcji wpisaną w plik, który trafia także do
|
||||
obrazu bez tego modułu. #}
|
||||
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||
+3
-1
@@ -36,4 +36,6 @@
|
||||
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
||||
</p>
|
||||
|
||||
<div id="promptResult">{% include "_prompt_result.html" %}</div>
|
||||
{# Wspólny handler nie zna nazwy „prompt_result" — oddaje wynik pod neutralnym
|
||||
„wynik_dodatku". Tłumaczenie nazwy należy do modułu. #}
|
||||
<div id="promptResult">{% with prompt_result = wynik_dodatku %}{% include "_prompt_result.html" %}{% endwith %}</div>
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Trasy wnoszone przez moduł. Bez tego katalogu w obrazie nie istnieją."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import Form
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.dodatki import katalog, klient
|
||||
|
||||
_logic: Any = None
|
||||
_szablony: Any = None
|
||||
_buduj_utc: Any = None
|
||||
_blad: Any = None
|
||||
|
||||
|
||||
def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any) -> None:
|
||||
"""Podpięcie tras i przekazanie modułowi tego, czego potrzebuje.
|
||||
|
||||
Zależności przychodzą z zewnątrz, zamiast być importowane: moduł nie może
|
||||
importować `app.main`, bo to cykl — a przede wszystkim dlatego, że zależność
|
||||
ma iść w JEDNĄ stronę. Aplikacja wie o istnieniu modułów opcjonalnych
|
||||
(przez neutralny most), moduł nie wie nic o aplikacji.
|
||||
"""
|
||||
global _logic, _szablony, _buduj_utc, _blad
|
||||
_logic, _szablony, _buduj_utc, _blad = logic, templates, buduj_utc, blad
|
||||
katalog.ustaw_zaleznosci(logic=logic, templates=templates)
|
||||
|
||||
@app.post("/horoscope/stream")
|
||||
def horoscope_stream( # noqa: ANN202
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
iso_utc, _ = _buduj_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 klient.strumien(_logic, payload):
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if event.get("type") == "result":
|
||||
event["html"] = _szablony.get_template("_prompt_result.html").render(
|
||||
prompt_result=event.get("result") or {}
|
||||
)
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except httpx.HTTPError as e:
|
||||
yield json.dumps({"type": "error", "message": _blad(e)},
|
||||
ensure_ascii=False) + "\n"
|
||||
|
||||
return StreamingResponse(relay(), media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-store",
|
||||
"X-Accel-Buffering": "no"})
|
||||
@@ -21,6 +21,8 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app import rozszerzenia
|
||||
|
||||
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
|
||||
|
||||
|
||||
@@ -55,9 +57,6 @@ EXTRAS: tuple[Feature, ...] = (
|
||||
Feature("advanced_calc", "Obliczenia zaawansowane",
|
||||
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
|
||||
"Rozszerzenia"),
|
||||
Feature("ai", "Generowanie tekstu przez model",
|
||||
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
|
||||
"Rozszerzenia"),
|
||||
Feature("files_input", "Wgrywanie i archiwizacja baz",
|
||||
"Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
|
||||
"zamrożony, ze znacznikiem czasu). Kasować może wyłącznie administrator.",
|
||||
@@ -66,7 +65,12 @@ EXTRAS: tuple[Feature, ...] = (
|
||||
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
|
||||
)
|
||||
|
||||
ALL: tuple[Feature, ...] = SCREENS + EXTRAS
|
||||
# Funkcje wnoszone przez moduły opcjonalne. Katalog nie wymienia ich z nazwy —
|
||||
# gdyby wymieniał, nazwa funkcji siedziałaby w kodzie także w obrazie, który tego
|
||||
# modułu nie zawiera, i `grep` po kontenerze pokazałby coś, czego tam nie ma.
|
||||
DOKLADANE: tuple[Feature, ...] = tuple(Feature(*k) for k in rozszerzenia.funkcje())
|
||||
|
||||
ALL: tuple[Feature, ...] = SCREENS + EXTRAS + DOKLADANE
|
||||
BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
|
||||
GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
|
||||
|
||||
@@ -100,7 +104,6 @@ ROUTES: dict[tuple[str, str], str | None] = {
|
||||
("POST", "/files/restore"): ADMIN,
|
||||
("POST", "/files/delete"): ADMIN,
|
||||
("POST", "/files/rules"): ADMIN,
|
||||
("POST", "/horoscope/stream"): "ai",
|
||||
("GET", "/accounts"): ADMIN,
|
||||
("POST", "/accounts/create"): ADMIN,
|
||||
("POST", "/accounts/update"): ADMIN,
|
||||
@@ -116,12 +119,15 @@ ROUTES: dict[tuple[str, str], str | None] = {
|
||||
("POST", "/wyloguj"): None,
|
||||
("POST", "/accounts/sesje"): ADMIN,
|
||||
}
|
||||
# Trasy modułów opcjonalnych. Dokładane po utworzeniu mapy, żeby wpis o module
|
||||
# nie musiał być wpisany w tym pliku z nazwy.
|
||||
ROUTES.update(rozszerzenia.trasy())
|
||||
|
||||
|
||||
# ── zasoby statyczne ─────────────────────────────────────────────────────
|
||||
# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
|
||||
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `models.js`
|
||||
# wymienia dostawców modeli, `compile.js` — składanie raportu. Dlatego każdy
|
||||
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `compile.js`
|
||||
# zdradza składanie raportu, a `wheelzoom.js` — istnienie kosmogramu. Dlatego każdy
|
||||
# zasób przechodzi przez tę samą bramkę co ekrany.
|
||||
#
|
||||
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
|
||||
@@ -142,7 +148,6 @@ STATIC: dict[str, frozenset[str] | str | None] = {
|
||||
"s-files.css": "files",
|
||||
"s-settings.css": "settings",
|
||||
"s-accounts.css": ADMIN,
|
||||
"x-ai.css": "ai",
|
||||
# skrypty
|
||||
"formsync.js": _FORMULARZE,
|
||||
"now.js": _LOKALIZACJA,
|
||||
@@ -153,16 +158,16 @@ STATIC: dict[str, frozenset[str] | str | None] = {
|
||||
"wheeltip.js": _KOLO,
|
||||
"copy.js": frozenset({"interpret", "timeline"}),
|
||||
"compile.js": "compile",
|
||||
"models.js": "ai",
|
||||
"progress.js": "ai",
|
||||
"natal.js": "ai",
|
||||
"predictions.js": "ai",
|
||||
# base.css celowo NIE MA tu wpisu: potrzebuje go ekran logowania, więc jest
|
||||
# publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
|
||||
# niczego, co nazywa jakąkolwiek funkcję.
|
||||
}
|
||||
|
||||
|
||||
# Zasoby modułów opcjonalnych — tak samo jak trasy.
|
||||
STATIC.update(rozszerzenia.zasoby())
|
||||
|
||||
|
||||
def static_required(name: str) -> frozenset[str] | str | None:
|
||||
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
|
||||
return STATIC.get(name, ADMIN)
|
||||
|
||||
@@ -19,13 +19,15 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi import (FastAPI, Depends, File, Form, HTTPException, Query, Request,
|
||||
UploadFile)
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
import base64
|
||||
|
||||
from app import accounts as accounts_store
|
||||
from app import rozszerzenia
|
||||
from app import chartwheel as chartwheel_mod
|
||||
from app import features
|
||||
from app import session
|
||||
@@ -42,7 +44,11 @@ from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
|
||||
app = FastAPI(title="astrololo · warstwa prezentacji",
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
# Katalogi modułów opcjonalnych idą PRZED wspólnym: moduł może dołożyć szablon,
|
||||
# którego wspólny zestaw nie ma. Wspólne szablony wołają je przez neutralne
|
||||
# gniazda `{% include "_dodatki_*" ignore missing %}` — bez modułu gniazdo jest
|
||||
# po prostu puste, a nie „wyłączone".
|
||||
templates = Jinja2Templates(directory=[*rozszerzenia.katalog_szablonow(), "app/templates"])
|
||||
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
|
||||
log = logging.getLogger("astrololo.prezentacja")
|
||||
logic = LogicClient()
|
||||
@@ -88,10 +94,13 @@ def _bez_komentarzy(tresc: str, sufiks: str) -> str:
|
||||
@lru_cache(maxsize=None)
|
||||
def _asset_body(name: str) -> bytes | None:
|
||||
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
|
||||
sciezka = (_STATIC_DIR / name).resolve()
|
||||
korzen = _STATIC_DIR.resolve()
|
||||
# Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
|
||||
if korzen not in sciezka.parents or not sciezka.is_file():
|
||||
for katalog in (*rozszerzenia.katalog_zasobow(), str(_STATIC_DIR)):
|
||||
korzen = Path(katalog).resolve()
|
||||
sciezka = (Path(katalog) / name).resolve()
|
||||
# Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
|
||||
if korzen in sciezka.parents and sciezka.is_file():
|
||||
break
|
||||
else:
|
||||
return None
|
||||
surowe = sciezka.read_bytes()
|
||||
sufiks = sciezka.suffix.lower()
|
||||
@@ -202,7 +211,10 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
# Akcja formularza → uprawnienie, którego wymaga. Akcji spoza tej mapy nie
|
||||
# ograniczamy; akcja Z mapy, na którą konto nie ma prawa, jest traktowana jak
|
||||
# NIEZNANA — czyli wraca do zachowania domyślnego ekranu.
|
||||
_AKCJE_POD_UPRAWNIENIEM = {"prompt": "ai", "horoscope": "ai", "export": "export"}
|
||||
# Akcje własne tego ekranu plus te, które wnoszą moduły opcjonalne. Aplikacja
|
||||
# nie wypisuje ich z nazwy — gdyby wypisywała, nazwa funkcji byłaby w kodzie
|
||||
# także tam, gdzie modułu nie ma.
|
||||
_AKCJE_POD_UPRAWNIENIEM = {"export": "export", **rozszerzenia.akcje()}
|
||||
|
||||
|
||||
def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str:
|
||||
@@ -218,25 +230,6 @@ def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str:
|
||||
return action
|
||||
|
||||
|
||||
def _llm_catalog_for(request: Request) -> dict:
|
||||
"""Katalog modeli — pusty, gdy konto nie generuje tekstu przez model.
|
||||
|
||||
Katalog trafia do strony jako blok JSON, więc bez tego ograniczenia nazwy
|
||||
dostawców, modeli i rozmiary okien kontekstu byłyby w źródle KAŻDEJ strony
|
||||
Interpretacji i Kalendarza, także dla kont, które o modelach nie mają prawa
|
||||
wiedzieć."""
|
||||
return _llm_catalog() if "ai" in _perms(request) else {}
|
||||
|
||||
|
||||
def _llm_catalog() -> dict:
|
||||
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
||||
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
||||
try:
|
||||
return logic.llm_models()
|
||||
except httpx.HTTPError:
|
||||
return {"providers": {}, "defaults": {}}
|
||||
|
||||
|
||||
def _report_records(report: dict | None) -> int:
|
||||
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
|
||||
|
||||
@@ -287,7 +280,7 @@ def chart_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "chart.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog_for(request)},
|
||||
**rozszerzenia.kontekst_szablonu(request)},
|
||||
)
|
||||
|
||||
|
||||
@@ -372,7 +365,7 @@ def compile_build(
|
||||
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
||||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
|
||||
):
|
||||
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
||||
"""Składa raport: horoskop liczymy TU NA NOWO, a części z przeglądarki (interpretacja
|
||||
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||||
|
||||
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
|
||||
@@ -417,7 +410,7 @@ def compile_build(
|
||||
def compile_pdf(payload: dict):
|
||||
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
|
||||
|
||||
Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje)
|
||||
Wejście z przeglądarki, bo część kawałków (interpretacja natalna i predykcje)
|
||||
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
|
||||
zawierał dokładnie to, co widać na stronie.
|
||||
|
||||
@@ -584,7 +577,7 @@ def interpret_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "interpret.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog_for(request)},
|
||||
**rozszerzenia.kontekst_szablonu(request)},
|
||||
)
|
||||
|
||||
|
||||
@@ -599,16 +592,16 @@ def interpret_run(
|
||||
lon: float = Form(0.0),
|
||||
group: bool = Form(False),
|
||||
action: str = Form("report"),
|
||||
prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form("local"),
|
||||
llm_model: str = Form(""),
|
||||
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
|
||||
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
|
||||
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
|
||||
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
|
||||
):
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||
"lat": lat, "lon": lon, "group": group, **dodatki}
|
||||
action = _dozwolona_akcja(request, action, "report")
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||
"llm_catalog": _llm_catalog_for(request)}
|
||||
**rozszerzenia.kontekst_szablonu(request)}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
@@ -627,16 +620,12 @@ def interpret_run(
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
|
||||
)
|
||||
if action == "prompt":
|
||||
ctx["prompt_result"] = logic.prompt(
|
||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
||||
provider=llm_provider, model=llm_model,
|
||||
)
|
||||
elif action == "horoscope":
|
||||
ctx["prompt_result"] = logic.horoscope(
|
||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||
)
|
||||
# Akcja może należeć do modułu opcjonalnego. `None` znaczy „nie jego",
|
||||
# więc ekran robi to, co robi normalnie.
|
||||
wynik = rozszerzenia.obsluz(action, profil="natal", iso_utc=iso_utc,
|
||||
lat=lat, lon=lon, dodatki=dodatki)
|
||||
if wynik is not None:
|
||||
ctx["wynik_dodatku"] = wynik
|
||||
else:
|
||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||
security.audit_records(request, _report_records(ctx["result"]))
|
||||
@@ -653,7 +642,7 @@ def timeline_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "timeline.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog_for(request)},
|
||||
**rozszerzenia.kontekst_szablonu(request)},
|
||||
)
|
||||
|
||||
|
||||
@@ -669,31 +658,25 @@ def timeline_run(
|
||||
from_date: str = Form(...),
|
||||
to_date: str = Form(...),
|
||||
action: str = Form("timeline"),
|
||||
prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form("local"),
|
||||
llm_model: str = Form(""),
|
||||
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
|
||||
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
|
||||
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
|
||||
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
|
||||
):
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date,
|
||||
**dodatki}
|
||||
action = _dozwolona_akcja(request, action, "timeline")
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||
"llm_catalog": _llm_catalog_for(request)}
|
||||
**rozszerzenia.kontekst_szablonu(request)}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
if action == "prompt":
|
||||
ctx["prompt_result"] = logic.prompt(
|
||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||||
provider=llm_provider, model=llm_model,
|
||||
)
|
||||
elif action == "horoscope":
|
||||
ctx["prompt_result"] = logic.horoscope(
|
||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||
from_date=from_date, to_date=to_date,
|
||||
)
|
||||
wynik = rozszerzenia.obsluz(action, profil="period", iso_utc=iso_utc,
|
||||
lat=lat, lon=lon, dodatki=dodatki,
|
||||
od=from_date, do=to_date)
|
||||
if wynik is not None:
|
||||
ctx["wynik_dodatku"] = wynik
|
||||
else:
|
||||
ctx["result"] = logic.timeline(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
@@ -706,61 +689,6 @@ 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")):
|
||||
@@ -946,7 +874,10 @@ def _accounts_context(request: Request, error: str = "", done: str = "") -> dict
|
||||
"users": users,
|
||||
"catalog": features.ALL,
|
||||
"screens": features.SCREENS,
|
||||
"extras": features.EXTRAS,
|
||||
# Rozszerzenia własne PLUS wniesione przez moduły opcjonalne — inaczej
|
||||
# administrator nie mógłby nadać uprawnienia do funkcji, która w tym
|
||||
# obrazie istnieje.
|
||||
"extras": features.EXTRAS + features.DOKLADANE,
|
||||
"admin_login": security.app_user(),
|
||||
"store_path": accounts_store.store_path(),
|
||||
"error": error, "done": done,
|
||||
@@ -1082,3 +1013,11 @@ def health() -> dict:
|
||||
# Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
|
||||
# Kubernetes i compose czytają wyłącznie kod odpowiedzi.
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── moduły opcjonalne ──────────────────────────────────────────────────────
|
||||
# Na końcu pliku, bo moduł dostaje gotowe zależności (klienta logiki, szablony,
|
||||
# pomocnicze funkcje), a te muszą już istnieć. Bez modułu w obrazie to wywołanie
|
||||
# nie robi nic i aplikacja nie ma pojęcia, że czegoś brakuje.
|
||||
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
|
||||
buduj_utc=_build_utc, blad=_logic_error)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Most do modułów opcjonalnych — CELOWO nic o nich nie wie.
|
||||
|
||||
Ten plik jest w każdym obrazie, także tam, gdzie żadnego modułu nie ma. Dlatego
|
||||
nie wolno w nim wymienić z nazwy ani jednej funkcji, którą moduł wnosi: nazwa
|
||||
w moście byłaby dokładnie tym śladem, którego wydzielanie ma się pozbyć.
|
||||
|
||||
Most odpowiada wyłącznie na pytanie „czy coś jest podpięte i co wnosi". Gdy nie
|
||||
ma nic, wszystkie odpowiedzi są puste, a aplikacja zachowuje się tak, jakby
|
||||
takiego pojęcia nigdy nie było — nie jak aplikacja z wyłączoną funkcją.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try: # pragma: no cover - zależy od obrazu
|
||||
from app import dodatki as _modul
|
||||
except ImportError: # obraz zbudowany bez tego katalogu
|
||||
_modul = None # type: ignore[assignment]
|
||||
|
||||
DOSTEPNY = _modul is not None
|
||||
|
||||
|
||||
def funkcje() -> tuple[tuple[str, str, str, str], ...]:
|
||||
"""Pozycje do katalogu funkcji — jako surowe krotki, nie obiekty.
|
||||
|
||||
Surowe, bo katalog buduje `features`, a gdyby moduł importował stamtąd klasę,
|
||||
powstałby cykl: features → most → moduł → features."""
|
||||
return _modul.FUNKCJA if _modul else ()
|
||||
|
||||
|
||||
def trasy() -> dict[tuple[str, str], str]:
|
||||
return _modul.TRASY if _modul else {}
|
||||
|
||||
|
||||
def zasoby() -> dict[str, Any]:
|
||||
return _modul.ZASOBY if _modul else {}
|
||||
|
||||
|
||||
def akcje() -> dict[str, str]:
|
||||
"""Akcje formularza wnoszone przez moduł: nazwa → wymagane uprawnienie."""
|
||||
return _modul.AKCJE if _modul else {}
|
||||
|
||||
|
||||
def pola_formularza():
|
||||
"""Zależność FastAPI deklarująca dodatkowe pola formularza.
|
||||
|
||||
Pola muszą być zadeklarowane w SYGNATURZE, żeby FastAPI je odczytało, a
|
||||
sygnatura wspólnego handlera nie może ich wymieniać. Zależność rozwiązuje
|
||||
jedno i drugie: moduł deklaruje własne pola u siebie, a handler wie tylko,
|
||||
że dostaje słownik."""
|
||||
if _modul:
|
||||
return _modul.pola
|
||||
return lambda: {}
|
||||
|
||||
|
||||
def kontekst_szablonu(request) -> dict:
|
||||
"""Co moduł dokłada do kontekstu szablonu. Bez modułu — nic."""
|
||||
return _modul.katalog_dla(request) if _modul else {}
|
||||
|
||||
|
||||
def obsluz(akcja: str, **dane) -> dict | None:
|
||||
"""Wykonanie akcji modułu. None = to nie jest jego akcja."""
|
||||
return _modul.obsluz(akcja, **dane) if _modul else None
|
||||
|
||||
|
||||
def zamontuj(app, **zaleznosci) -> None:
|
||||
"""Podpięcie tras modułu i przekazanie mu tego, czego potrzebuje."""
|
||||
if _modul:
|
||||
_modul.zamontuj(app, **zaleznosci)
|
||||
|
||||
|
||||
def katalog_szablonow() -> list[str]:
|
||||
"""Katalogi szablonów modułu — nakładane PRZED wspólnymi."""
|
||||
if not _modul:
|
||||
return []
|
||||
import pathlib
|
||||
|
||||
return [str(pathlib.Path(_modul.__file__).parent / "templates")]
|
||||
|
||||
|
||||
def katalog_zasobow() -> list[str]:
|
||||
if not _modul:
|
||||
return []
|
||||
import pathlib
|
||||
|
||||
return [str(pathlib.Path(_modul.__file__).parent / "static")]
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Ochrona dostępu do aplikacji (LOG-32).
|
||||
|
||||
Rdzeniem produktu są oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
|
||||
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
|
||||
na wielu ścieżkach (`/significators`, `/interpret` i dalszych), więc BRAK
|
||||
uwierzytelnienia oznacza, że każdy w sieci może je wypompować — bez udziału
|
||||
jakiegokolwiek modelu językowego. Ten moduł zamyka tę drogę.
|
||||
|
||||
@@ -24,7 +24,7 @@ DZIENNIK AUDYTOWY (PRE-17). Każde żądanie do chronionej ścieżki zostawia wp
|
||||
kto, kiedy, jaka ścieżka, status, ile rekordów (gdy handler to poda przez
|
||||
`audit_records`). To jedyny sposób, by zauważyć POWOLNE wypompowywanie bazy przez
|
||||
osobę uprawnioną — pojedyncze zapytanie wygląda niewinnie, suma już nie.
|
||||
Świadomie NIE logujemy treści żądań, promptów ani rekordów — logi to kolejny
|
||||
Świadomie NIE logujemy treści żądań ani rekordów — logi to kolejny
|
||||
nośnik wycieku; liczby i metadane wystarczą do wykrycia nadużycia.
|
||||
|
||||
UWAGA: bez kont ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev). Wtedy przy
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
//
|
||||
// Raport ma trzy kawałki liczone w różnych miejscach:
|
||||
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
|
||||
// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
|
||||
// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
|
||||
// 2. interpretacja natalna — z zakładki Interpretacje,
|
||||
// 3. predykcje okresowe — z zakładki Kalendarz.
|
||||
// Kawałki 2 i 3 wnosi moduł opcjonalny. Gdy go nie ma, magazynów nie ma również
|
||||
// i czytanie ich zwraca pusto — raport składa się wtedy z samego horoskopu.
|
||||
//
|
||||
// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy.
|
||||
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
|
||||
@@ -86,7 +88,7 @@
|
||||
}
|
||||
|
||||
// ── PDF (PRE-24) ────────────────────────────────────────────────────
|
||||
// Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
|
||||
// Te kawałki mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
|
||||
// do serwera, który dokłada kosmogram i woła usługę render.
|
||||
function formData() {
|
||||
function v(name) {
|
||||
|
||||
@@ -102,7 +102,7 @@ body.wheel-zoom-open { overflow: hidden; }
|
||||
.readiness .rdy-missing { color: #e0c060; }
|
||||
.report-head { margin: 1.5rem 0 .5rem; }
|
||||
.report-head h2 { margin: 0; font-size: 1.4rem; }
|
||||
/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
|
||||
/* Bloki tekstu bywają długie — white-space:pre-wrap zachowuje akapity
|
||||
bez przepisywania ich na HTML. */
|
||||
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
||||
|
||||
@@ -34,9 +34,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||
.leaflet-control-attribution { font-size: .68rem; }
|
||||
/* Generator promptu do LLM (LOG-29/30) */
|
||||
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||
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; }
|
||||
|
||||
@@ -31,9 +31,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||
.leaflet-control-attribution { font-size: .68rem; }
|
||||
/* Generator promptu do LLM (LOG-29/30) */
|
||||
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||
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; }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% set nav_active = "compile" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-compile.css') }}">
|
||||
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||
{% include "_dodatki_compile_style.html" ignore missing %}
|
||||
{% endblock %}
|
||||
{% block head_scripts %}
|
||||
<script defer src="{{ static('formsync.js') }}"></script>
|
||||
@@ -12,19 +12,17 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI
|
||||
{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja wymieniająca
|
||||
wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu
|
||||
jest takim samym śladem jak przycisk. #}
|
||||
{% if can(request, 'ai') %}
|
||||
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
|
||||
zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
|
||||
{% else %}
|
||||
<p class="sub">Składa policzony horoskop w jeden raport. Dane pobiera z pozostałych
|
||||
zakładek — nie trzeba nic przepisywać.</p>
|
||||
{% endif %}
|
||||
{# Jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie. Wariant
|
||||
warunkowy wymieniałby funkcję z nazwy — a zdanie opisujące funkcję jest takim
|
||||
samym śladem jak przycisk. #}
|
||||
<p class="sub">Składa w jeden raport policzony horoskop i wszystkie zapamiętane
|
||||
części. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
|
||||
|
||||
{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
|
||||
kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #}
|
||||
kawałków mieszka w magazynie lokalnym, nie na serwerze. #}
|
||||
<div id="readiness"></div>
|
||||
|
||||
<form method="post" action="/compile">
|
||||
@@ -137,18 +135,9 @@ zakładek — nie trzeba nic przepisywać.</p>
|
||||
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
|
||||
{% include "_result_tables.html" %}
|
||||
|
||||
{% if can(request, 'ai') %}
|
||||
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
|
||||
<div id="reportNatal"></div>
|
||||
<div id="reportPredictions"></div>
|
||||
{% endif %}
|
||||
{% include "_dodatki_compile_wyniki.html" ignore missing %}
|
||||
{% endif %}
|
||||
|
||||
{% if can(request, 'ai') %}
|
||||
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
|
||||
każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #}
|
||||
<script src="{{ static('natal.js') }}"></script>
|
||||
<script src="{{ static('predictions.js') }}"></script>
|
||||
{% endif %}
|
||||
{% include "_dodatki_compile_skrypty.html" ignore missing %}
|
||||
<script src="{{ static('compile.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% set nav_active = "interpret" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-interpret.css') }}">
|
||||
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||
{% include "_dodatki_interpret_style.html" ignore missing %}
|
||||
{% endblock %}
|
||||
{% block head_scripts %}
|
||||
<script defer src="{{ static('formsync.js') }}"></script>
|
||||
@@ -45,16 +45,12 @@
|
||||
{# Cały blok generowania — budżet, dostawca, model i wynik — istnieje TYLKO
|
||||
dla kont z tym uprawnieniem. Nie ukrywamy go stylem: bez uprawnienia nie ma
|
||||
go w źródle strony, razem z katalogiem modeli, który blok wstrzykuje. #}
|
||||
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
|
||||
{% if can(request, 'ai') %}<p class="muted small" id="natalNote"></p>{% endif %}
|
||||
{% include "_dodatki_interpret_formularz.html" ignore missing %}
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||
{% if can(request, 'export') %}<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>{% endif %}
|
||||
{% if can(request, 'ai') %}
|
||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||
{% endif %}
|
||||
{% include "_dodatki_interpret_akcje.html" ignore missing %}
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
@@ -111,12 +107,7 @@
|
||||
|
||||
<script src="{{ static('now.js') }}"></script>
|
||||
<script src="{{ static('copy.js') }}"></script>
|
||||
{# Skrypty obsługujące WYŁĄCZNIE generowanie przez model. Bez uprawnienia nie ma
|
||||
ich nawet w odsyłaczach — sam adres pliku podpowiadałby, co program potrafi. #}
|
||||
{% if can(request, 'ai') %}
|
||||
<script src="{{ static('models.js') }}"></script>
|
||||
<script src="{{ static('progress.js') }}"></script>
|
||||
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
|
||||
<script src="{{ static('natal.js') }}"></script>
|
||||
{% endif %}
|
||||
{# Skrypty modułów opcjonalnych. Bez modułu w obrazie gniazdo jest puste —
|
||||
sam adres pliku podpowiadałby, co program potrafi. #}
|
||||
{% include "_dodatki_interpret_skrypty.html" ignore missing %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% set nav_active = "timeline" %}
|
||||
|
||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-timeline.css') }}">
|
||||
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||
{% include "_dodatki_timeline_style.html" ignore missing %}
|
||||
{% endblock %}
|
||||
{% block head_scripts %}
|
||||
<script defer src="{{ static('formsync.js') }}"></script>
|
||||
@@ -47,14 +47,11 @@
|
||||
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
||||
</label>
|
||||
</div>
|
||||
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
|
||||
{% include "_dodatki_timeline_formularz.html" ignore missing %}
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
||||
{% if can(request, 'ai') %}
|
||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||
{% endif %}
|
||||
{% include "_dodatki_timeline_akcje.html" ignore missing %}
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
@@ -102,10 +99,5 @@
|
||||
|
||||
<script src="{{ static('now.js') }}"></script>
|
||||
<script src="{{ static('copy.js') }}"></script>
|
||||
{% if can(request, 'ai') %}
|
||||
<script src="{{ static('models.js') }}"></script>
|
||||
<script src="{{ static('progress.js') }}"></script>
|
||||
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
|
||||
<script src="{{ static('predictions.js') }}"></script>
|
||||
{% endif %}
|
||||
{% include "_dodatki_timeline_skrypty.html" ignore missing %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user