astrololo: generowanie tekstu jako moduł odłączalny (2/5)
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4m44s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4m44s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
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 is contained in:
@@ -4,7 +4,11 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też testy,
|
||||||
|
# README i skrypty pomocnicze — a plik testowy nazywa funkcje wprost, więc
|
||||||
|
# w obrazie zbudowanym bez danego modułu byłby dokładnie tym śladem, którego
|
||||||
|
# wydzielenie ma się pozbyć. Przy okazji obraz jest mniejszy.
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -40,6 +40,29 @@ class LogicClient:
|
|||||||
payload=payload, headers=_auth_headers(),
|
payload=payload, headers=_auth_headers(),
|
||||||
link=_link())
|
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]:
|
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||||
return self._post("/api/query", payload, settings.http_timeout)
|
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}
|
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))
|
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]:
|
def bases(self) -> dict[str, Any]:
|
||||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
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}",
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||||
payload=payload, headers=_auth_headers(), link=_link())
|
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(
|
def timeline(
|
||||||
self, when_utc_iso: str, lat: float, lon: float,
|
self, when_utc_iso: str, lat: float, lon: float,
|
||||||
from_date: str, to_date: str, interpret: bool = True,
|
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-ok { color: #7fd18b; }
|
||||||
.progress-log li.log-warn { color: #e0c060; }
|
.progress-log li.log-warn { color: #e0c060; }
|
||||||
.progress-log li.log-err { color: #ef6b6b; }
|
.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ć.
|
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
||||||
</p>
|
</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 dataclasses import dataclass
|
||||||
|
|
||||||
|
from app import rozszerzenia
|
||||||
|
|
||||||
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
|
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",
|
Feature("advanced_calc", "Obliczenia zaawansowane",
|
||||||
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
|
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
|
||||||
"Rozszerzenia"),
|
"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",
|
Feature("files_input", "Wgrywanie i archiwizacja baz",
|
||||||
"Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
|
"Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
|
||||||
"zamrożony, ze znacznikiem czasu). Kasować może wyłącznie administrator.",
|
"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"),
|
"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}
|
BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
|
||||||
GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
|
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/restore"): ADMIN,
|
||||||
("POST", "/files/delete"): ADMIN,
|
("POST", "/files/delete"): ADMIN,
|
||||||
("POST", "/files/rules"): ADMIN,
|
("POST", "/files/rules"): ADMIN,
|
||||||
("POST", "/horoscope/stream"): "ai",
|
|
||||||
("GET", "/accounts"): ADMIN,
|
("GET", "/accounts"): ADMIN,
|
||||||
("POST", "/accounts/create"): ADMIN,
|
("POST", "/accounts/create"): ADMIN,
|
||||||
("POST", "/accounts/update"): ADMIN,
|
("POST", "/accounts/update"): ADMIN,
|
||||||
@@ -116,12 +119,15 @@ ROUTES: dict[tuple[str, str], str | None] = {
|
|||||||
("POST", "/wyloguj"): None,
|
("POST", "/wyloguj"): None,
|
||||||
("POST", "/accounts/sesje"): ADMIN,
|
("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 ─────────────────────────────────────────────────────
|
# ── zasoby statyczne ─────────────────────────────────────────────────────
|
||||||
# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
|
# 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`
|
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `compile.js`
|
||||||
# wymienia dostawców modeli, `compile.js` — składanie raportu. Dlatego każdy
|
# zdradza składanie raportu, a `wheelzoom.js` — istnienie kosmogramu. Dlatego każdy
|
||||||
# zasób przechodzi przez tę samą bramkę co ekrany.
|
# zasób przechodzi przez tę samą bramkę co ekrany.
|
||||||
#
|
#
|
||||||
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
|
# 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-files.css": "files",
|
||||||
"s-settings.css": "settings",
|
"s-settings.css": "settings",
|
||||||
"s-accounts.css": ADMIN,
|
"s-accounts.css": ADMIN,
|
||||||
"x-ai.css": "ai",
|
|
||||||
# skrypty
|
# skrypty
|
||||||
"formsync.js": _FORMULARZE,
|
"formsync.js": _FORMULARZE,
|
||||||
"now.js": _LOKALIZACJA,
|
"now.js": _LOKALIZACJA,
|
||||||
@@ -153,16 +158,16 @@ STATIC: dict[str, frozenset[str] | str | None] = {
|
|||||||
"wheeltip.js": _KOLO,
|
"wheeltip.js": _KOLO,
|
||||||
"copy.js": frozenset({"interpret", "timeline"}),
|
"copy.js": frozenset({"interpret", "timeline"}),
|
||||||
"compile.js": "compile",
|
"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
|
# 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ć
|
# publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
|
||||||
# niczego, co nazywa jakąkolwiek funkcję.
|
# 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:
|
def static_required(name: str) -> frozenset[str] | str | None:
|
||||||
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
|
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
|
||||||
return STATIC.get(name, ADMIN)
|
return STATIC.get(name, ADMIN)
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ from functools import lru_cache
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
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.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
from app import accounts as accounts_store
|
from app import accounts as accounts_store
|
||||||
|
from app import rozszerzenia
|
||||||
from app import chartwheel as chartwheel_mod
|
from app import chartwheel as chartwheel_mod
|
||||||
from app import features
|
from app import features
|
||||||
from app import session
|
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.
|
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
|
||||||
app = FastAPI(title="astrololo · warstwa prezentacji",
|
app = FastAPI(title="astrololo · warstwa prezentacji",
|
||||||
docs_url=None, redoc_url=None, openapi_url=None)
|
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).
|
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
|
||||||
log = logging.getLogger("astrololo.prezentacja")
|
log = logging.getLogger("astrololo.prezentacja")
|
||||||
logic = LogicClient()
|
logic = LogicClient()
|
||||||
@@ -88,10 +94,13 @@ def _bez_komentarzy(tresc: str, sufiks: str) -> str:
|
|||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
def _asset_body(name: str) -> bytes | None:
|
def _asset_body(name: str) -> bytes | None:
|
||||||
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
|
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
|
||||||
sciezka = (_STATIC_DIR / name).resolve()
|
for katalog in (*rozszerzenia.katalog_zasobow(), str(_STATIC_DIR)):
|
||||||
korzen = _STATIC_DIR.resolve()
|
korzen = Path(katalog).resolve()
|
||||||
|
sciezka = (Path(katalog) / name).resolve()
|
||||||
# Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
|
# 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():
|
if korzen in sciezka.parents and sciezka.is_file():
|
||||||
|
break
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
surowe = sciezka.read_bytes()
|
surowe = sciezka.read_bytes()
|
||||||
sufiks = sciezka.suffix.lower()
|
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
|
# 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
|
# ograniczamy; akcja Z mapy, na którą konto nie ma prawa, jest traktowana jak
|
||||||
# NIEZNANA — czyli wraca do zachowania domyślnego ekranu.
|
# 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:
|
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
|
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:
|
def _report_records(report: dict | None) -> int:
|
||||||
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
|
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
|
||||||
|
|
||||||
@@ -287,7 +280,7 @@ def chart_form(request: Request):
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "chart.html",
|
request, "chart.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"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
|
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
|
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).
|
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||||||
|
|
||||||
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
|
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
|
||||||
@@ -417,7 +410,7 @@ def compile_build(
|
|||||||
def compile_pdf(payload: dict):
|
def compile_pdf(payload: dict):
|
||||||
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
|
"""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
|
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
|
||||||
zawierał dokładnie to, co widać na stronie.
|
zawierał dokładnie to, co widać na stronie.
|
||||||
|
|
||||||
@@ -584,7 +577,7 @@ def interpret_form(request: Request):
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "interpret.html",
|
request, "interpret.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"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),
|
lon: float = Form(0.0),
|
||||||
group: bool = Form(False),
|
group: bool = Form(False),
|
||||||
action: str = Form("report"),
|
action: str = Form("report"),
|
||||||
prompt_budget: str = Form("medium"),
|
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
|
||||||
llm_provider: str = Form("local"),
|
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
|
||||||
llm_model: str = Form(""),
|
# 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,
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
"lat": lat, "lon": lon, "group": group, **dodatki}
|
||||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
|
||||||
action = _dozwolona_akcja(request, action, "report")
|
action = _dozwolona_akcja(request, action, "report")
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
"llm_catalog": _llm_catalog_for(request)}
|
**rozszerzenia.kontekst_szablonu(request)}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
@@ -627,16 +620,12 @@ def interpret_run(
|
|||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
|
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
|
||||||
)
|
)
|
||||||
if action == "prompt":
|
# Akcja może należeć do modułu opcjonalnego. `None` znaczy „nie jego",
|
||||||
ctx["prompt_result"] = logic.prompt(
|
# więc ekran robi to, co robi normalnie.
|
||||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
wynik = rozszerzenia.obsluz(action, profil="natal", iso_utc=iso_utc,
|
||||||
provider=llm_provider, model=llm_model,
|
lat=lat, lon=lon, dodatki=dodatki)
|
||||||
)
|
if wynik is not None:
|
||||||
elif action == "horoscope":
|
ctx["wynik_dodatku"] = wynik
|
||||||
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,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||||
security.audit_records(request, _report_records(ctx["result"]))
|
security.audit_records(request, _report_records(ctx["result"]))
|
||||||
@@ -653,7 +642,7 @@ def timeline_form(request: Request):
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "timeline.html",
|
request, "timeline.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"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(...),
|
from_date: str = Form(...),
|
||||||
to_date: str = Form(...),
|
to_date: str = Form(...),
|
||||||
action: str = Form("timeline"),
|
action: str = Form("timeline"),
|
||||||
prompt_budget: str = Form("medium"),
|
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
|
||||||
llm_provider: str = Form("local"),
|
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
|
||||||
llm_model: str = Form(""),
|
# 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,
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
"lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date,
|
||||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
**dodatki}
|
||||||
action = _dozwolona_akcja(request, action, "timeline")
|
action = _dozwolona_akcja(request, action, "timeline")
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
"llm_catalog": _llm_catalog_for(request)}
|
**rozszerzenia.kontekst_szablonu(request)}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
if action == "prompt":
|
wynik = rozszerzenia.obsluz(action, profil="period", iso_utc=iso_utc,
|
||||||
ctx["prompt_result"] = logic.prompt(
|
lat=lat, lon=lon, dodatki=dodatki,
|
||||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
od=from_date, do=to_date)
|
||||||
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
if wynik is not None:
|
||||||
provider=llm_provider, model=llm_model,
|
ctx["wynik_dodatku"] = wynik
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
ctx["result"] = logic.timeline(
|
ctx["result"] = logic.timeline(
|
||||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
@@ -706,61 +689,6 @@ def timeline_run(
|
|||||||
return templates.TemplateResponse(request, "timeline.html", ctx)
|
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
|
|
||||||
@app.post("/horoscope/stream")
|
|
||||||
def horoscope_stream(
|
|
||||||
profile: str = Form("natal"),
|
|
||||||
date: str = Form(...),
|
|
||||||
time: str = Form(...),
|
|
||||||
tz_offset: float = Form(0.0),
|
|
||||||
lat: float = Form(0.0),
|
|
||||||
lon: float = Form(0.0),
|
|
||||||
prompt_budget: str = Form("medium"),
|
|
||||||
llm_provider: str = Form("local"),
|
|
||||||
llm_model: str = Form(""),
|
|
||||||
from_date: str = Form(""),
|
|
||||||
to_date: str = Form(""),
|
|
||||||
):
|
|
||||||
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
|
|
||||||
|
|
||||||
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
|
|
||||||
przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku.
|
|
||||||
"""
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
try:
|
|
||||||
iso_utc, _ = _build_utc(date, time, tz_offset)
|
|
||||||
except ValueError as e:
|
|
||||||
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
|
||||||
|
|
||||||
payload: dict = {
|
|
||||||
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
|
|
||||||
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
|
|
||||||
}
|
|
||||||
if profile == "period" and from_date and to_date:
|
|
||||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
|
||||||
|
|
||||||
def relay():
|
|
||||||
try:
|
|
||||||
for raw in logic.horoscope_stream(payload):
|
|
||||||
try:
|
|
||||||
event = json.loads(raw)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if event.get("type") == "result":
|
|
||||||
html = templates.get_template("_prompt_result.html").render(
|
|
||||||
prompt_result=event.get("result") or {}
|
|
||||||
)
|
|
||||||
event["html"] = html
|
|
||||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
yield json.dumps({"type": "error", "message": _logic_error(e)},
|
|
||||||
ensure_ascii=False) + "\n"
|
|
||||||
|
|
||||||
return StreamingResponse(relay(), media_type="application/x-ndjson",
|
|
||||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||||||
@app.get("/geocode")
|
@app.get("/geocode")
|
||||||
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||||||
@@ -946,7 +874,10 @@ def _accounts_context(request: Request, error: str = "", done: str = "") -> dict
|
|||||||
"users": users,
|
"users": users,
|
||||||
"catalog": features.ALL,
|
"catalog": features.ALL,
|
||||||
"screens": features.SCREENS,
|
"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(),
|
"admin_login": security.app_user(),
|
||||||
"store_path": accounts_store.store_path(),
|
"store_path": accounts_store.store_path(),
|
||||||
"error": error, "done": done,
|
"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.
|
# Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
|
||||||
# Kubernetes i compose czytają wyłącznie kod odpowiedzi.
|
# Kubernetes i compose czytają wyłącznie kod odpowiedzi.
|
||||||
return {"status": "ok"}
|
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).
|
"""Ochrona dostępu do aplikacji (LOG-32).
|
||||||
|
|
||||||
Rdzeniem produktu są oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
|
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
|
uwierzytelnienia oznacza, że każdy w sieci może je wypompować — bez udziału
|
||||||
jakiegokolwiek modelu językowego. Ten moduł zamyka tę drogę.
|
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
|
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
|
`audit_records`). To jedyny sposób, by zauważyć POWOLNE wypompowywanie bazy przez
|
||||||
osobę uprawnioną — pojedyncze zapytanie wygląda niewinnie, suma już nie.
|
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.
|
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
|
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:
|
// Raport ma trzy kawałki liczone w różnych miejscach:
|
||||||
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
|
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
|
||||||
// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
|
// 2. interpretacja natalna — z zakładki Interpretacje,
|
||||||
// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
|
// 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.
|
// 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
|
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
|
||||||
@@ -86,7 +88,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── PDF (PRE-24) ────────────────────────────────────────────────────
|
// ── 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.
|
// do serwera, który dokłada kosmogram i woła usługę render.
|
||||||
function formData() {
|
function formData() {
|
||||||
function v(name) {
|
function v(name) {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ body.wheel-zoom-open { overflow: hidden; }
|
|||||||
.readiness .rdy-missing { color: #e0c060; }
|
.readiness .rdy-missing { color: #e0c060; }
|
||||||
.report-head { margin: 1.5rem 0 .5rem; }
|
.report-head { margin: 1.5rem 0 .5rem; }
|
||||||
.report-head h2 { margin: 0; font-size: 1.4rem; }
|
.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. */
|
bez przepisywania ich na HTML. */
|
||||||
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
.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; }
|
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; }
|
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||||
.leaflet-control-attribution { font-size: .68rem; }
|
.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; }
|
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||||
.leaflet-control-attribution { font-size: .68rem; }
|
.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" %}
|
{% set nav_active = "compile" %}
|
||||||
|
|
||||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-compile.css') }}">
|
{% 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 %}
|
{% endblock %}
|
||||||
{% block head_scripts %}
|
{% block head_scripts %}
|
||||||
<script defer src="{{ static('formsync.js') }}"></script>
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
@@ -12,19 +12,17 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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
|
wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu
|
||||||
jest takim samym śladem jak przycisk. #}
|
jest takim samym śladem jak przycisk. #}
|
||||||
{% if can(request, 'ai') %}
|
{# Jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie. Wariant
|
||||||
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
|
warunkowy wymieniałby funkcję z nazwy — a zdanie opisujące funkcję jest takim
|
||||||
zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
|
samym śladem jak przycisk. #}
|
||||||
{% else %}
|
<p class="sub">Składa w jeden raport policzony horoskop i wszystkie zapamiętane
|
||||||
<p class="sub">Składa policzony horoskop w jeden raport. Dane pobiera z pozostałych
|
części. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
|
||||||
zakładek — nie trzeba nic przepisywać.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
|
{# 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>
|
<div id="readiness"></div>
|
||||||
|
|
||||||
<form method="post" action="/compile">
|
<form method="post" action="/compile">
|
||||||
@@ -137,18 +135,9 @@ zakładek — nie trzeba nic przepisywać.</p>
|
|||||||
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
|
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
|
||||||
{% include "_result_tables.html" %}
|
{% include "_result_tables.html" %}
|
||||||
|
|
||||||
{% if can(request, 'ai') %}
|
{% include "_dodatki_compile_wyniki.html" ignore missing %}
|
||||||
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
|
|
||||||
<div id="reportNatal"></div>
|
|
||||||
<div id="reportPredictions"></div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can(request, 'ai') %}
|
{% include "_dodatki_compile_skrypty.html" ignore missing %}
|
||||||
{# 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 %}
|
|
||||||
<script src="{{ static('compile.js') }}"></script>
|
<script src="{{ static('compile.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% set nav_active = "interpret" %}
|
{% set nav_active = "interpret" %}
|
||||||
|
|
||||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-interpret.css') }}">
|
{% 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 %}
|
{% endblock %}
|
||||||
{% block head_scripts %}
|
{% block head_scripts %}
|
||||||
<script defer src="{{ static('formsync.js') }}"></script>
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
@@ -45,16 +45,12 @@
|
|||||||
{# Cały blok generowania — budżet, dostawca, model i wynik — istnieje TYLKO
|
{# 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
|
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. #}
|
go w źródle strony, razem z katalogiem modeli, który blok wstrzykuje. #}
|
||||||
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
|
{% include "_dodatki_interpret_formularz.html" ignore missing %}
|
||||||
{% if can(request, 'ai') %}<p class="muted small" id="natalNote"></p>{% endif %}
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit" name="action" value="report">Szukaj interpretacji</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, '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') %}
|
{% include "_dodatki_interpret_akcje.html" ignore missing %}
|
||||||
<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 %}
|
|
||||||
<span id="geoNote" class="muted small"></span>
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -111,12 +107,7 @@
|
|||||||
|
|
||||||
<script src="{{ static('now.js') }}"></script>
|
<script src="{{ static('now.js') }}"></script>
|
||||||
<script src="{{ static('copy.js') }}"></script>
|
<script src="{{ static('copy.js') }}"></script>
|
||||||
{# Skrypty obsługujące WYŁĄCZNIE generowanie przez model. Bez uprawnienia nie ma
|
{# Skrypty modułów opcjonalnych. Bez modułu w obrazie gniazdo jest puste —
|
||||||
ich nawet w odsyłaczach — sam adres pliku podpowiadałby, co program potrafi. #}
|
sam adres pliku podpowiadałby, co program potrafi. #}
|
||||||
{% if can(request, 'ai') %}
|
{% include "_dodatki_interpret_skrypty.html" ignore missing %}
|
||||||
<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 %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% set nav_active = "timeline" %}
|
{% set nav_active = "timeline" %}
|
||||||
|
|
||||||
{% block styles %}<link rel="stylesheet" href="{{ static('s-timeline.css') }}">
|
{% 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 %}
|
{% endblock %}
|
||||||
{% block head_scripts %}
|
{% block head_scripts %}
|
||||||
<script defer src="{{ static('formsync.js') }}"></script>
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
@@ -47,14 +47,11 @@
|
|||||||
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
|
{% include "_dodatki_timeline_formularz.html" ignore missing %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
||||||
{% if can(request, 'ai') %}
|
{% include "_dodatki_timeline_akcje.html" ignore missing %}
|
||||||
<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 %}
|
|
||||||
<span id="geoNote" class="muted small"></span>
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -102,10 +99,5 @@
|
|||||||
|
|
||||||
<script src="{{ static('now.js') }}"></script>
|
<script src="{{ static('now.js') }}"></script>
|
||||||
<script src="{{ static('copy.js') }}"></script>
|
<script src="{{ static('copy.js') }}"></script>
|
||||||
{% if can(request, 'ai') %}
|
{% include "_dodatki_timeline_skrypty.html" ignore missing %}
|
||||||
<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 %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Szablon widziany tak, jak zobaczy go przeglądarka — z wstawkami modułów.
|
||||||
|
|
||||||
|
Szablony wspólne mają neutralne gniazda (`{% include "_dodatki_*" %}`), a treść
|
||||||
|
wstawianą w gniazdo trzyma moduł. Testy strukturalne sprawdzają kolejność
|
||||||
|
skryptów i obecność odsyłaczy, więc muszą patrzeć na CAŁOŚĆ — inaczej pilnowałyby
|
||||||
|
połowy strony i przepuściłyby zamianę kolejności, która psuje działanie.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
GNIAZDO = re.compile(r'\{%\s*include\s*"(_dodatki_[\w-]+\.html)"[^%]*%\}')
|
||||||
|
|
||||||
|
|
||||||
|
def zloz(nazwa: str) -> str:
|
||||||
|
"""Treść szablonu z wstawkami rozwiniętymi W MIEJSCU gniazda."""
|
||||||
|
tresc = (APP / "templates" / nazwa).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def wstaw(m: re.Match) -> str:
|
||||||
|
plik = APP / "dodatki" / "templates" / m.group(1)
|
||||||
|
return plik.read_text(encoding="utf-8") if plik.exists() else ""
|
||||||
|
|
||||||
|
return GNIAZDO.sub(wstaw, tresc)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Granica modułu generowania jest KATALOGIEM, nie warunkiem w szablonie.
|
||||||
|
|
||||||
|
Astrololo zawiera `app/ai/`. Astroklient — nie, i to jedyna różnica między nimi
|
||||||
|
po stronie funkcji astrologicznych. Żeby usunięcie katalogu naprawdę usuwało
|
||||||
|
funkcję, poza nim nie może zostać ani jedno słowo o niej: ani w kodzie, ani
|
||||||
|
w szablonie, ani w komentarzu, ani zaprzeczeniem („bez promptów").
|
||||||
|
|
||||||
|
Warunek `{% if can(request, 'ai') %}` w szablonie wspólnym tego nie daje. Funkcji
|
||||||
|
nie WIDAĆ, ale plik dalej leży w obrazie i dalej zawiera jej nazwy — `grep` po
|
||||||
|
kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie.
|
||||||
|
|
||||||
|
Ten test jest jedynym miejscem poza modułem, gdzie te słowa wolno wypisać.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
MODUL = APP / "dodatki"
|
||||||
|
|
||||||
|
SLOWA = [
|
||||||
|
# nazwa funkcji i jej słownictwo
|
||||||
|
"prompt", "LLM", "model językow", "generowanie tekstu", "sztuczn",
|
||||||
|
# dostawcy i modele
|
||||||
|
"anthropic", "openai", "gpt", "claude",
|
||||||
|
# angielska nazwa akcji (polski „horoskop" to nazwa EKRANU i jest w porządku)
|
||||||
|
"horoscope",
|
||||||
|
# nazwy plików modułu — sam odsyłacz podpowiada, co program potrafi
|
||||||
|
"x-ai.css", "models.js", "progress.js", "natal.js", "predictions.js",
|
||||||
|
"_prompt_block", "_prompt_result",
|
||||||
|
]
|
||||||
|
WZORCE = [(r"(?<![\w-])AI(?![\w-])", "skrót nazwy funkcji")]
|
||||||
|
|
||||||
|
|
||||||
|
def _pliki_poza_modulem():
|
||||||
|
for f in sorted(APP.rglob("*")):
|
||||||
|
if not f.is_file() or f.suffix == ".pyc" or "__pycache__" in str(f):
|
||||||
|
continue
|
||||||
|
if MODUL in f.parents:
|
||||||
|
continue
|
||||||
|
yield f
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_word_about_generation_lives_outside_the_module():
|
||||||
|
grzechy = []
|
||||||
|
for f in _pliki_poza_modulem():
|
||||||
|
try:
|
||||||
|
tresc = f.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
gdzie = f.relative_to(APP.parent)
|
||||||
|
for slowo in SLOWA:
|
||||||
|
if re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}", tresc):
|
||||||
|
grzechy.append(f"{gdzie}: „{slowo}”")
|
||||||
|
for wzor, powod in WZORCE:
|
||||||
|
if re.search(wzor, tresc):
|
||||||
|
grzechy.append(f"{gdzie}: „AI” — {powod}")
|
||||||
|
assert not grzechy, (
|
||||||
|
"\nPoza app/ai/ padły słowa opisujące generowanie. Granica ma być "
|
||||||
|
"katalogiem, więc to, co je nazywa, musi mieszkać w środku:\n "
|
||||||
|
+ "\n ".join(grzechy))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_module_is_the_only_thing_that_has_to_go():
|
||||||
|
"""Kontrola pozytywna: te słowa MAJĄ padać w module.
|
||||||
|
|
||||||
|
Bez niej test wyżej przechodziłby także wtedy, gdyby funkcja po prostu
|
||||||
|
przestała istnieć albo została wydrążona."""
|
||||||
|
tresc = "\n".join(f.read_text(encoding="utf-8") for f in MODUL.rglob("*")
|
||||||
|
if f.is_file() and f.suffix in (".py", ".html", ".css", ".js"))
|
||||||
|
for musi in ("prompt", "horoscope", "x-ai.css", "models.js"):
|
||||||
|
assert musi in tresc, f"moduł nie zawiera „{musi}” — czy on jeszcze cokolwiek robi?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_the_module_leaves_a_working_application():
|
||||||
|
"""Katalog ma być ODŁĄCZALNY, nie tylko oddzielony.
|
||||||
|
|
||||||
|
Most (`app.rozszerzenia`) musi odpowiadać sensownie, gdy modułu nie ma —
|
||||||
|
inaczej astroklient nie wystartuje, a dowiemy się o tym dopiero przy
|
||||||
|
budowaniu jego obrazu."""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app import rozszerzenia
|
||||||
|
|
||||||
|
zapamietane = rozszerzenia._modul
|
||||||
|
try:
|
||||||
|
rozszerzenia._modul = None
|
||||||
|
assert rozszerzenia.funkcje() == ()
|
||||||
|
assert rozszerzenia.trasy() == {}
|
||||||
|
assert rozszerzenia.zasoby() == {}
|
||||||
|
assert rozszerzenia.akcje() == {}
|
||||||
|
assert rozszerzenia.kontekst_szablonu(None) == {}
|
||||||
|
assert rozszerzenia.obsluz("prompt", cokolwiek=1) is None
|
||||||
|
assert rozszerzenia.katalog_szablonow() == []
|
||||||
|
assert rozszerzenia.katalog_zasobow() == []
|
||||||
|
assert rozszerzenia.pola_formularza()() == {}
|
||||||
|
rozszerzenia.zamontuj(None) # ma nie wybuchnąć
|
||||||
|
finally:
|
||||||
|
rozszerzenia._modul = zapamietane
|
||||||
|
importlib.reload(sys.modules["app.rozszerzenia"]) if False else None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_bridge_itself_names_nothing():
|
||||||
|
"""Most jest w KAŻDYM obrazie, więc on sam nie może niczego wymienić."""
|
||||||
|
tresc = (APP / "rozszerzenia.py").read_text(encoding="utf-8")
|
||||||
|
for slowo in SLOWA + ["ai"]:
|
||||||
|
assert not re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}(?![\w-])", tresc), \
|
||||||
|
f"most wymienia „{slowo}” — a ma nie wiedzieć, co jest podpięte"
|
||||||
@@ -11,11 +11,16 @@ import pathlib
|
|||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
TPL = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
|
from tests.skladanie import zloz
|
||||||
|
|
||||||
|
# Szablon z rozwiniętymi wstawkami modułów — patrz tests/skladanie.py.
|
||||||
|
TPL = zloz("compile.html")
|
||||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
|
JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
|
||||||
NATAL_JS = (APP / "static" / "natal.js").read_text(encoding="utf-8")
|
# Skrypty generowania mieszkają w module `app/ai/` — katalogu, którego
|
||||||
INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8")
|
# w obrazie bez tej funkcji po prostu nie ma.
|
||||||
|
NATAL_JS = (APP / "dodatki" / "static" / "natal.js").read_text(encoding="utf-8")
|
||||||
|
INTERPRET = zloz("interpret.html")
|
||||||
MAIN = (APP / "main.py").read_text(encoding="utf-8")
|
MAIN = (APP / "main.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
|
|||||||
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||||
# pilnuje osobny test pokrycia.
|
# pilnuje osobny test pokrycia.
|
||||||
CSS = "\n".join(f.read_text(encoding="utf-8")
|
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||||
for f in sorted((APP / "static").glob("*.css")))
|
for f in sorted([*(APP / "static").glob("*.css"),
|
||||||
|
*(APP / "dodatki" / "static").glob("*.css")]))
|
||||||
|
|
||||||
|
|
||||||
def test_comparison_table_scrolls_instead_of_widening_the_page():
|
def test_comparison_table_scrolls_instead_of_widening_the_page():
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ nie może puchnąć od powtórzonych prób na tym samym okresie.
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
JS = (APP / "static" / "predictions.js").read_text(encoding="utf-8")
|
# Skrypty generowania mieszkają w module `app/ai/` — katalogu, którego
|
||||||
PROGRESS = (APP / "static" / "progress.js").read_text(encoding="utf-8")
|
# w obrazie bez tej funkcji po prostu nie ma.
|
||||||
TIMELINE = (APP / "templates" / "timeline.html").read_text(encoding="utf-8")
|
JS = (APP / "dodatki" / "static" / "predictions.js").read_text(encoding="utf-8")
|
||||||
|
PROGRESS = (APP / "dodatki" / "static" / "progress.js").read_text(encoding="utf-8")
|
||||||
|
from tests.skladanie import zloz
|
||||||
|
|
||||||
|
TIMELINE = zloz("timeline.html")
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────── podpięcie ───────────────────────────────
|
# ─────────────────────────────── podpięcie ───────────────────────────────
|
||||||
|
|||||||
@@ -54,11 +54,15 @@ def _client(monkeypatch):
|
|||||||
wolania = []
|
wolania = []
|
||||||
monkeypatch.setattr(logic, "report", lambda **kw: (wolania.append("report"), RAPORT)[1])
|
monkeypatch.setattr(logic, "report", lambda **kw: (wolania.append("report"), RAPORT)[1])
|
||||||
monkeypatch.setattr(logic, "timeline", lambda **kw: (wolania.append("timeline"), KALENDARZ)[1])
|
monkeypatch.setattr(logic, "timeline", lambda **kw: (wolania.append("timeline"), KALENDARZ)[1])
|
||||||
monkeypatch.setattr(logic, "prompt",
|
# Generowanie mieszka w module `app/ai/` i ma własnego klienta — wspólny
|
||||||
lambda **kw: (wolania.append("prompt"), {"prompt": "TREŚĆ"})[1])
|
# klient nie zna tych metod, bo jego plik jedzie do każdego obrazu.
|
||||||
monkeypatch.setattr(logic, "horoscope",
|
from app.dodatki import klient
|
||||||
lambda **kw: (wolania.append("horoscope"), {"text": "TREŚĆ"})[1])
|
|
||||||
monkeypatch.setattr(logic, "llm_models", lambda: {"providers": {"openai": ["gpt-x"]}})
|
monkeypatch.setattr(klient, "prompt",
|
||||||
|
lambda *a, **kw: (wolania.append("prompt"), {"prompt": "TREŚĆ"})[1])
|
||||||
|
monkeypatch.setattr(klient, "horoscope",
|
||||||
|
lambda *a, **kw: (wolania.append("horoscope"), {"text": "TREŚĆ"})[1])
|
||||||
|
monkeypatch.setattr(klient, "modele", lambda *a: {"providers": {"openai": ["gpt-x"]}})
|
||||||
return TestClient(app, follow_redirects=False), wolania
|
return TestClient(app, follow_redirects=False), wolania
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8")
|
|||||||
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||||
# pilnuje osobny test pokrycia.
|
# pilnuje osobny test pokrycia.
|
||||||
CSS = "\n".join(f.read_text(encoding="utf-8")
|
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||||
for f in sorted((APP / "static").glob("*.css")))
|
for f in sorted([*(APP / "static").glob("*.css"),
|
||||||
|
*(APP / "dodatki" / "static").glob("*.css")]))
|
||||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8")
|
|||||||
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||||
# pilnuje osobny test pokrycia.
|
# pilnuje osobny test pokrycia.
|
||||||
CSS = "\n".join(f.read_text(encoding="utf-8")
|
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||||
for f in sorted((APP / "static").glob("*.css")))
|
for f in sorted([*(APP / "static").glob("*.css"),
|
||||||
|
*(APP / "dodatki" / "static").glob("*.css")]))
|
||||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user