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

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:
2026-08-26 12:23:08 +02:00
parent 320a0ab24e
commit d094a145b1
43 changed files with 722 additions and 277 deletions
+5 -1
View File
@@ -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")
@@ -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 %}
@@ -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"})
+17 -12
View File
@@ -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)
+61 -122
View File
@@ -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()
# Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz. sciezka = (Path(katalog) / name).resolve()
if korzen not in sciezka.parents or not sciezka.is_file(): # 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 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)
+86
View File
@@ -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")]
+2 -2
View File
@@ -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
+5 -3
View File
@@ -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 bywa 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 %}
+25
View File
@@ -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"
+8 -3
View File
@@ -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
+2 -1
View File
@@ -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")