From d094a145b1514ce5b567add2c2b628046e11f08e Mon Sep 17 00:00:00 2001
From: migatu
Date: Wed, 26 Aug 2026 12:23:08 +0200
Subject: [PATCH] =?UTF-8?q?astrololo:=20generowanie=20tekstu=20jako=20modu?=
=?UTF-8?q?=C5=82=20od=C5=82=C4=85czalny=20(2/5)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
services/presentation/Dockerfile | 6 +-
.../presentation/app/clients/logic_client.py | 84 +++-----
services/presentation/app/dodatki/__init__.py | 23 +++
services/presentation/app/dodatki/katalog.py | 91 +++++++++
services/presentation/app/dodatki/klient.py | 62 ++++++
.../app/{ => dodatki}/static/models.js | 0
.../app/{ => dodatki}/static/natal.js | 0
.../app/{ => dodatki}/static/predictions.js | 0
.../app/{ => dodatki}/static/progress.js | 0
.../app/{ => dodatki}/static/x-ai.css | 7 +
.../templates/_dodatki_compile_skrypty.html | 9 +
.../templates/_dodatki_compile_style.html | 4 +
.../templates/_dodatki_compile_wyniki.html | 8 +
.../templates/_dodatki_interpret_akcje.html | 7 +
.../_dodatki_interpret_formularz.html | 5 +
.../templates/_dodatki_interpret_skrypty.html | 9 +
.../templates/_dodatki_interpret_style.html | 4 +
.../templates/_dodatki_timeline_akcje.html | 7 +
.../_dodatki_timeline_formularz.html | 4 +
.../templates/_dodatki_timeline_skrypty.html | 9 +
.../templates/_dodatki_timeline_style.html | 4 +
.../templates/_prompt_block.html | 4 +-
.../templates/_prompt_result.html | 0
services/presentation/app/dodatki/trasy.py | 81 ++++++++
services/presentation/app/features.py | 29 +--
services/presentation/app/main.py | 183 ++++++------------
services/presentation/app/rozszerzenia.py | 86 ++++++++
services/presentation/app/security.py | 4 +-
services/presentation/app/static/compile.js | 8 +-
.../presentation/app/static/s-compile.css | 2 +-
.../presentation/app/static/s-interpret.css | 6 -
.../presentation/app/static/s-timeline.css | 6 -
.../presentation/app/templates/compile.html | 31 +--
.../presentation/app/templates/interpret.html | 21 +-
.../presentation/app/templates/timeline.html | 16 +-
services/presentation/tests/skladanie.py | 25 +++
.../tests/test_ai_tylko_w_module.py | 110 +++++++++++
services/presentation/tests/test_compile.py | 11 +-
.../presentation/tests/test_house_compare.py | 3 +-
.../presentation/tests/test_predictions.py | 10 +-
.../presentation/tests/test_ukrywanie_ai.py | 14 +-
services/presentation/tests/test_wheeltip.py | 3 +-
services/presentation/tests/test_wheelzoom.py | 3 +-
43 files changed, 722 insertions(+), 277 deletions(-)
create mode 100644 services/presentation/app/dodatki/__init__.py
create mode 100644 services/presentation/app/dodatki/katalog.py
create mode 100644 services/presentation/app/dodatki/klient.py
rename services/presentation/app/{ => dodatki}/static/models.js (100%)
rename services/presentation/app/{ => dodatki}/static/natal.js (100%)
rename services/presentation/app/{ => dodatki}/static/predictions.js (100%)
rename services/presentation/app/{ => dodatki}/static/progress.js (100%)
rename services/presentation/app/{ => dodatki}/static/x-ai.css (81%)
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_compile_skrypty.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_compile_style.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_compile_wyniki.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_interpret_akcje.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_interpret_formularz.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_interpret_skrypty.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_interpret_style.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_timeline_akcje.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_timeline_formularz.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_timeline_skrypty.html
create mode 100644 services/presentation/app/dodatki/templates/_dodatki_timeline_style.html
rename services/presentation/app/{ => dodatki}/templates/_prompt_block.html (88%)
rename services/presentation/app/{ => dodatki}/templates/_prompt_result.html (100%)
create mode 100644 services/presentation/app/dodatki/trasy.py
create mode 100644 services/presentation/app/rozszerzenia.py
create mode 100644 services/presentation/tests/skladanie.py
create mode 100644 services/presentation/tests/test_ai_tylko_w_module.py
diff --git a/services/presentation/Dockerfile b/services/presentation/Dockerfile
index c7df951..69e9da3 100644
--- a/services/presentation/Dockerfile
+++ b/services/presentation/Dockerfile
@@ -4,7 +4,11 @@ WORKDIR /app
COPY 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
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py
index 755c106..d92cf2b 100644
--- a/services/presentation/app/clients/logic_client.py
+++ b/services/presentation/app/clients/logic_client.py
@@ -40,6 +40,29 @@ class LogicClient:
payload=payload, headers=_auth_headers(),
link=_link())
+ # ── transport dla modułów opcjonalnych ─────────────────────────────────
+ # Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
+ # wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
+ # w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
+ # międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
+ def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
+ return self._post(sciezka, payload, timeout)
+
+ def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
+ with httpx.Client(timeout=timeout or settings.http_timeout) as client:
+ return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
+ headers=_auth_headers(), link=_link())
+
+ def strumien(self, sciezka: str, payload: dict[str, Any]):
+ """Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
+
+ Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
+ włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
+ with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
+ yield from link_crypto.stream_lines(
+ client, f"{self.base_url}{sciezka}",
+ payload=payload, headers=_auth_headers(), link=_link())
+
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
return self._post("/api/query", payload, settings.http_timeout)
@@ -97,61 +120,6 @@ class LogicClient:
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
- def prompt(
- self, profile: str, when_utc_iso: str, lat: float, lon: float,
- budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
- provider: str | None = None, model: str | None = None,
- ) -> dict[str, Any]:
- """Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
-
- `provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
- znaków zależy wtedy od okna kontekstu konkretnego modelu."""
- payload: dict[str, Any] = {
- "profile": profile, "when_utc": when_utc_iso,
- "lat": lat, "lon": lon, "budget": budget, "provider": provider,
- "model": model,
- }
- if from_date and to_date:
- payload["from_date"], payload["to_date"] = from_date, to_date
- return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
-
- def horoscope(
- self, profile: str, when_utc_iso: str, lat: float, lon: float,
- budget: str = "medium", provider: str | None = None, model: str | None = None,
- from_date: str | None = None, to_date: str | None = None,
- ) -> dict[str, Any]:
- """Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
-
- Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
- """
- payload: dict[str, Any] = {
- "profile": profile, "when_utc": when_utc_iso,
- "lat": lat, "lon": lon, "budget": budget,
- }
- if provider:
- payload["provider"] = provider
- if model:
- payload["model"] = model
- if from_date and to_date:
- payload["from_date"], payload["to_date"] = from_date, to_date
- return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
-
- def horoscope_stream(self, payload: dict[str, Any]):
- """Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
-
- Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
- więc cisza na łączu nie zostanie wzięta za zerwanie.
-
- Idzie przez szyfrowane łącze jak reszta ruchu w dół (PRE-16). Bez tego przy
- włączonym `LINK_ENCRYPTION_REQUIRED` serwer odrzuciłby nieszyfrowane żądanie
- i okno postępu przestałoby działać. `stream_lines` pieczętuje żądanie i
- odszyfrowuje odpowiedź ramka po ramce, zachowując dostarczanie na żywo.
- """
- with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
- yield from link_crypto.stream_lines(
- client, f"{self.base_url}/chart/horoscope/stream",
- payload=payload, headers=_auth_headers(), link=_link())
-
def bases(self) -> dict[str, Any]:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
with httpx.Client(timeout=settings.http_timeout) as client:
@@ -185,12 +153,6 @@ class LogicClient:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(), link=_link())
- def llm_models(self) -> dict[str, Any]:
- """Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
- with httpx.Client(timeout=settings.http_timeout) as client:
- return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
- headers=_auth_headers(), link=_link())
-
def timeline(
self, when_utc_iso: str, lat: float, lon: float,
from_date: str, to_date: str, interpret: bool = True,
diff --git a/services/presentation/app/dodatki/__init__.py b/services/presentation/app/dodatki/__init__.py
new file mode 100644
index 0000000..ac16f43
--- /dev/null
+++ b/services/presentation/app/dodatki/__init__.py
@@ -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"]
diff --git a/services/presentation/app/dodatki/katalog.py b/services/presentation/app/dodatki/katalog.py
new file mode 100644
index 0000000..63ea367
--- /dev/null
+++ b/services/presentation/app/dodatki/katalog.py
@@ -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)
diff --git a/services/presentation/app/dodatki/klient.py b/services/presentation/app/dodatki/klient.py
new file mode 100644
index 0000000..4a45ffa
--- /dev/null
+++ b/services/presentation/app/dodatki/klient.py
@@ -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")
diff --git a/services/presentation/app/static/models.js b/services/presentation/app/dodatki/static/models.js
similarity index 100%
rename from services/presentation/app/static/models.js
rename to services/presentation/app/dodatki/static/models.js
diff --git a/services/presentation/app/static/natal.js b/services/presentation/app/dodatki/static/natal.js
similarity index 100%
rename from services/presentation/app/static/natal.js
rename to services/presentation/app/dodatki/static/natal.js
diff --git a/services/presentation/app/static/predictions.js b/services/presentation/app/dodatki/static/predictions.js
similarity index 100%
rename from services/presentation/app/static/predictions.js
rename to services/presentation/app/dodatki/static/predictions.js
diff --git a/services/presentation/app/static/progress.js b/services/presentation/app/dodatki/static/progress.js
similarity index 100%
rename from services/presentation/app/static/progress.js
rename to services/presentation/app/dodatki/static/progress.js
diff --git a/services/presentation/app/static/x-ai.css b/services/presentation/app/dodatki/static/x-ai.css
similarity index 81%
rename from services/presentation/app/static/x-ai.css
rename to services/presentation/app/dodatki/static/x-ai.css
index 0cc2d13..8999b61 100644
--- a/services/presentation/app/static/x-ai.css
+++ b/services/presentation/app/dodatki/static/x-ai.css
@@ -29,3 +29,10 @@
.progress-log li.log-ok { color: #7fd18b; }
.progress-log li.log-warn { color: #e0c060; }
.progress-log li.log-err { color: #ef6b6b; }
+
+/* Generator promptu do LLM (LOG-29/30) */
+textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
+ background: #12132a; color: var(--ink); border: 1px solid var(--line);
+ border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
+textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
diff --git a/services/presentation/app/dodatki/templates/_dodatki_compile_skrypty.html b/services/presentation/app/dodatki/templates/_dodatki_compile_skrypty.html
new file mode 100644
index 0000000..d93ca6c
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_compile_skrypty.html
@@ -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. #}
+
+
+{% endif %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_compile_style.html b/services/presentation/app/dodatki/templates/_dodatki_compile_style.html
new file mode 100644
index 0000000..f29619c
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_compile_style.html
@@ -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') %}{% endif %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_compile_wyniki.html b/services/presentation/app/dodatki/templates/_dodatki_compile_wyniki.html
new file mode 100644
index 0000000..33d80c6
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_compile_wyniki.html
@@ -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. #}
+
+
+{% endif %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_interpret_akcje.html b/services/presentation/app/dodatki/templates/_dodatki_interpret_akcje.html
new file mode 100644
index 0000000..a198336
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_interpret_akcje.html
@@ -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') %}
+
+
+{% endif %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_interpret_formularz.html b/services/presentation/app/dodatki/templates/_dodatki_interpret_formularz.html
new file mode 100644
index 0000000..2352965
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_interpret_formularz.html
@@ -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" %}
+{% endif %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_interpret_skrypty.html b/services/presentation/app/dodatki/templates/_dodatki_interpret_skrypty.html
new file mode 100644
index 0000000..6abae85
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_interpret_skrypty.html
@@ -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') %}
+
+
+{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
+
+{% endif %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_interpret_style.html b/services/presentation/app/dodatki/templates/_dodatki_interpret_style.html
new file mode 100644
index 0000000..f29619c
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_interpret_style.html
@@ -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') %}{% endif %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_timeline_akcje.html b/services/presentation/app/dodatki/templates/_dodatki_timeline_akcje.html
new file mode 100644
index 0000000..a198336
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_timeline_akcje.html
@@ -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') %}
+
+
+{% endif %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_timeline_formularz.html b/services/presentation/app/dodatki/templates/_dodatki_timeline_formularz.html
new file mode 100644
index 0000000..6dca642
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_timeline_formularz.html
@@ -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 %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_timeline_skrypty.html b/services/presentation/app/dodatki/templates/_dodatki_timeline_skrypty.html
new file mode 100644
index 0000000..6325884
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_timeline_skrypty.html
@@ -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') %}
+
+
+{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
+
+{% endif %}
diff --git a/services/presentation/app/dodatki/templates/_dodatki_timeline_style.html b/services/presentation/app/dodatki/templates/_dodatki_timeline_style.html
new file mode 100644
index 0000000..f29619c
--- /dev/null
+++ b/services/presentation/app/dodatki/templates/_dodatki_timeline_style.html
@@ -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') %}{% endif %}
diff --git a/services/presentation/app/templates/_prompt_block.html b/services/presentation/app/dodatki/templates/_prompt_block.html
similarity index 88%
rename from services/presentation/app/templates/_prompt_block.html
rename to services/presentation/app/dodatki/templates/_prompt_block.html
index 440be4c..4c1bb2b 100644
--- a/services/presentation/app/templates/_prompt_block.html
+++ b/services/presentation/app/dodatki/templates/_prompt_block.html
@@ -36,4 +36,6 @@
sieć. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
-
{% include "_prompt_result.html" %}
+{# Wspólny handler nie zna nazwy „prompt_result" — oddaje wynik pod neutralnym
+ „wynik_dodatku". Tłumaczenie nazwy należy do modułu. #}
+
{% with prompt_result = wynik_dodatku %}{% include "_prompt_result.html" %}{% endwith %}
diff --git a/services/presentation/app/templates/_prompt_result.html b/services/presentation/app/dodatki/templates/_prompt_result.html
similarity index 100%
rename from services/presentation/app/templates/_prompt_result.html
rename to services/presentation/app/dodatki/templates/_prompt_result.html
diff --git a/services/presentation/app/dodatki/trasy.py b/services/presentation/app/dodatki/trasy.py
new file mode 100644
index 0000000..607bf21
--- /dev/null
+++ b/services/presentation/app/dodatki/trasy.py
@@ -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"})
diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py
index 1d6f61b..4251c97 100644
--- a/services/presentation/app/features.py
+++ b/services/presentation/app/features.py
@@ -21,6 +21,8 @@ from __future__ import annotations
from dataclasses import dataclass
+from app import rozszerzenia
+
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
@@ -55,9 +57,6 @@ EXTRAS: tuple[Feature, ...] = (
Feature("advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"Rozszerzenia"),
- Feature("ai", "Generowanie tekstu przez model",
- "Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
- "Rozszerzenia"),
Feature("files_input", "Wgrywanie i archiwizacja baz",
"Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
"zamrożony, ze znacznikiem czasu). Kasować może wyłącznie administrator.",
@@ -66,7 +65,12 @@ EXTRAS: tuple[Feature, ...] = (
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
)
-ALL: tuple[Feature, ...] = SCREENS + EXTRAS
+# Funkcje wnoszone przez moduły opcjonalne. Katalog nie wymienia ich z nazwy —
+# gdyby wymieniał, nazwa funkcji siedziałaby w kodzie także w obrazie, który tego
+# modułu nie zawiera, i `grep` po kontenerze pokazałby coś, czego tam nie ma.
+DOKLADANE: tuple[Feature, ...] = tuple(Feature(*k) for k in rozszerzenia.funkcje())
+
+ALL: tuple[Feature, ...] = SCREENS + EXTRAS + DOKLADANE
BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
@@ -100,7 +104,6 @@ ROUTES: dict[tuple[str, str], str | None] = {
("POST", "/files/restore"): ADMIN,
("POST", "/files/delete"): ADMIN,
("POST", "/files/rules"): ADMIN,
- ("POST", "/horoscope/stream"): "ai",
("GET", "/accounts"): ADMIN,
("POST", "/accounts/create"): ADMIN,
("POST", "/accounts/update"): ADMIN,
@@ -116,12 +119,15 @@ ROUTES: dict[tuple[str, str], str | None] = {
("POST", "/wyloguj"): None,
("POST", "/accounts/sesje"): ADMIN,
}
+# Trasy modułów opcjonalnych. Dokładane po utworzeniu mapy, żeby wpis o module
+# nie musiał być wpisany w tym pliku z nazwy.
+ROUTES.update(rozszerzenia.trasy())
# ── zasoby statyczne ─────────────────────────────────────────────────────
# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
-# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `models.js`
-# wymienia dostawców modeli, `compile.js` — składanie raportu. Dlatego każdy
+# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `compile.js`
+# zdradza składanie raportu, a `wheelzoom.js` — istnienie kosmogramu. Dlatego każdy
# zasób przechodzi przez tę samą bramkę co ekrany.
#
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
@@ -142,7 +148,6 @@ STATIC: dict[str, frozenset[str] | str | None] = {
"s-files.css": "files",
"s-settings.css": "settings",
"s-accounts.css": ADMIN,
- "x-ai.css": "ai",
# skrypty
"formsync.js": _FORMULARZE,
"now.js": _LOKALIZACJA,
@@ -153,16 +158,16 @@ STATIC: dict[str, frozenset[str] | str | None] = {
"wheeltip.js": _KOLO,
"copy.js": frozenset({"interpret", "timeline"}),
"compile.js": "compile",
- "models.js": "ai",
- "progress.js": "ai",
- "natal.js": "ai",
- "predictions.js": "ai",
# base.css celowo NIE MA tu wpisu: potrzebuje go ekran logowania, więc jest
# publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
# niczego, co nazywa jakąkolwiek funkcję.
}
+# Zasoby modułów opcjonalnych — tak samo jak trasy.
+STATIC.update(rozszerzenia.zasoby())
+
+
def static_required(name: str) -> frozenset[str] | str | None:
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
return STATIC.get(name, ADMIN)
diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index 5306692..93ea4f5 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -19,13 +19,15 @@ from functools import lru_cache
from pathlib import Path
import httpx
-from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
+from fastapi import (FastAPI, Depends, File, Form, HTTPException, Query, Request,
+ UploadFile)
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
import base64
from app import accounts as accounts_store
+from app import rozszerzenia
from app import chartwheel as chartwheel_mod
from app import features
from app import session
@@ -42,7 +44,11 @@ from app.config import DEFAULT_LOCATION_LABEL, default_form
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
app = FastAPI(title="astrololo · warstwa prezentacji",
docs_url=None, redoc_url=None, openapi_url=None)
-templates = Jinja2Templates(directory="app/templates")
+# Katalogi modułów opcjonalnych idą PRZED wspólnym: moduł może dołożyć szablon,
+# którego wspólny zestaw nie ma. Wspólne szablony wołają je przez neutralne
+# gniazda `{% include "_dodatki_*" ignore missing %}` — bez modułu gniazdo jest
+# po prostu puste, a nie „wyłączone".
+templates = Jinja2Templates(directory=[*rozszerzenia.katalog_szablonow(), "app/templates"])
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
log = logging.getLogger("astrololo.prezentacja")
logic = LogicClient()
@@ -88,10 +94,13 @@ def _bez_komentarzy(tresc: str, sufiks: str) -> str:
@lru_cache(maxsize=None)
def _asset_body(name: str) -> bytes | None:
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
- sciezka = (_STATIC_DIR / name).resolve()
- korzen = _STATIC_DIR.resolve()
- # Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
- if korzen not in sciezka.parents or not sciezka.is_file():
+ for katalog in (*rozszerzenia.katalog_zasobow(), str(_STATIC_DIR)):
+ korzen = Path(katalog).resolve()
+ sciezka = (Path(katalog) / name).resolve()
+ # Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
+ if korzen in sciezka.parents and sciezka.is_file():
+ break
+ else:
return None
surowe = sciezka.read_bytes()
sufiks = sciezka.suffix.lower()
@@ -202,7 +211,10 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
# Akcja formularza → uprawnienie, którego wymaga. Akcji spoza tej mapy nie
# ograniczamy; akcja Z mapy, na którą konto nie ma prawa, jest traktowana jak
# NIEZNANA — czyli wraca do zachowania domyślnego ekranu.
-_AKCJE_POD_UPRAWNIENIEM = {"prompt": "ai", "horoscope": "ai", "export": "export"}
+# Akcje własne tego ekranu plus te, które wnoszą moduły opcjonalne. Aplikacja
+# nie wypisuje ich z nazwy — gdyby wypisywała, nazwa funkcji byłaby w kodzie
+# także tam, gdzie modułu nie ma.
+_AKCJE_POD_UPRAWNIENIEM = {"export": "export", **rozszerzenia.akcje()}
def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str:
@@ -218,25 +230,6 @@ def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str:
return action
-def _llm_catalog_for(request: Request) -> dict:
- """Katalog modeli — pusty, gdy konto nie generuje tekstu przez model.
-
- Katalog trafia do strony jako blok JSON, więc bez tego ograniczenia nazwy
- dostawców, modeli i rozmiary okien kontekstu byłyby w źródle KAŻDEJ strony
- Interpretacji i Kalendarza, także dla kont, które o modelach nie mają prawa
- wiedzieć."""
- return _llm_catalog() if "ai" in _perms(request) else {}
-
-
-def _llm_catalog() -> dict:
- """Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
- pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
- try:
- return logic.llm_models()
- except httpx.HTTPError:
- return {"providers": {}, "defaults": {}}
-
-
def _report_records(report: dict | None) -> int:
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
@@ -287,7 +280,7 @@ def chart_form(request: Request):
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
- "llm_catalog": _llm_catalog_for(request)},
+ **rozszerzenia.kontekst_szablonu(request)},
)
@@ -372,7 +365,7 @@ def compile_build(
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
):
- """Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
+ """Składa raport: horoskop liczymy TU NA NOWO, a części z przeglądarki (interpretacja
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
@@ -417,7 +410,7 @@ def compile_build(
def compile_pdf(payload: dict):
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
- Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje)
+ Wejście z przeglądarki, bo część kawałków (interpretacja natalna i predykcje)
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
zawierał dokładnie to, co widać na stronie.
@@ -584,7 +577,7 @@ def interpret_form(request: Request):
return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
- "llm_catalog": _llm_catalog_for(request)},
+ **rozszerzenia.kontekst_szablonu(request)},
)
@@ -599,16 +592,16 @@ def interpret_run(
lon: float = Form(0.0),
group: bool = Form(False),
action: str = Form("report"),
- prompt_budget: str = Form("medium"),
- llm_provider: str = Form("local"),
- llm_model: str = Form(""),
+ # Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
+ # nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
+ # funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
+ dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
- "lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
- "llm_provider": llm_provider, "llm_model": llm_model}
+ "lat": lat, "lon": lon, "group": group, **dodatki}
action = _dozwolona_akcja(request, action, "report")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
- "llm_catalog": _llm_catalog_for(request)}
+ **rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
@@ -627,16 +620,12 @@ def interpret_run(
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
)
- if action == "prompt":
- ctx["prompt_result"] = logic.prompt(
- profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
- provider=llm_provider, model=llm_model,
- )
- elif action == "horoscope":
- ctx["prompt_result"] = logic.horoscope(
- profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
- budget=prompt_budget, provider=llm_provider, model=llm_model,
- )
+ # Akcja może należeć do modułu opcjonalnego. `None` znaczy „nie jego",
+ # więc ekran robi to, co robi normalnie.
+ wynik = rozszerzenia.obsluz(action, profil="natal", iso_utc=iso_utc,
+ lat=lat, lon=lon, dodatki=dodatki)
+ if wynik is not None:
+ ctx["wynik_dodatku"] = wynik
else:
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
security.audit_records(request, _report_records(ctx["result"]))
@@ -653,7 +642,7 @@ def timeline_form(request: Request):
return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
- "llm_catalog": _llm_catalog_for(request)},
+ **rozszerzenia.kontekst_szablonu(request)},
)
@@ -669,31 +658,25 @@ def timeline_run(
from_date: str = Form(...),
to_date: str = Form(...),
action: str = Form("timeline"),
- prompt_budget: str = Form("medium"),
- llm_provider: str = Form("local"),
- llm_model: str = Form(""),
+ # Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
+ # nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
+ # funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
+ dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
- form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
- "from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
- "llm_provider": llm_provider, "llm_model": llm_model}
+ form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
+ "lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date,
+ **dodatki}
action = _dozwolona_akcja(request, action, "timeline")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
- "llm_catalog": _llm_catalog_for(request)}
+ **rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
- if action == "prompt":
- ctx["prompt_result"] = logic.prompt(
- profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
- budget=prompt_budget, from_date=from_date, to_date=to_date,
- provider=llm_provider, model=llm_model,
- )
- elif action == "horoscope":
- ctx["prompt_result"] = logic.horoscope(
- profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
- budget=prompt_budget, provider=llm_provider, model=llm_model,
- from_date=from_date, to_date=to_date,
- )
+ wynik = rozszerzenia.obsluz(action, profil="period", iso_utc=iso_utc,
+ lat=lat, lon=lon, dodatki=dodatki,
+ od=from_date, do=to_date)
+ if wynik is not None:
+ ctx["wynik_dodatku"] = wynik
else:
ctx["result"] = logic.timeline(
when_utc_iso=iso_utc, lat=lat, lon=lon,
@@ -706,61 +689,6 @@ def timeline_run(
return templates.TemplateResponse(request, "timeline.html", ctx)
-# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
-@app.post("/horoscope/stream")
-def horoscope_stream(
- profile: str = Form("natal"),
- date: str = Form(...),
- time: str = Form(...),
- tz_offset: float = Form(0.0),
- lat: float = Form(0.0),
- lon: float = Form(0.0),
- prompt_budget: str = Form("medium"),
- llm_provider: str = Form("local"),
- llm_model: str = Form(""),
- from_date: str = Form(""),
- to_date: str = Form(""),
-):
- """Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
-
- Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
- przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku.
- """
- from fastapi.responses import StreamingResponse
-
- try:
- iso_utc, _ = _build_utc(date, time, tz_offset)
- except ValueError as e:
- return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
-
- payload: dict = {
- "profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
- "budget": prompt_budget, "provider": llm_provider, "model": llm_model,
- }
- if profile == "period" and from_date and to_date:
- payload["from_date"], payload["to_date"] = from_date, to_date
-
- def relay():
- try:
- for raw in logic.horoscope_stream(payload):
- try:
- event = json.loads(raw)
- except ValueError:
- continue
- if event.get("type") == "result":
- html = templates.get_template("_prompt_result.html").render(
- prompt_result=event.get("result") or {}
- )
- event["html"] = html
- yield json.dumps(event, ensure_ascii=False) + "\n"
- except httpx.HTTPError as e:
- yield json.dumps({"type": "error", "message": _logic_error(e)},
- ensure_ascii=False) + "\n"
-
- return StreamingResponse(relay(), media_type="application/x-ndjson",
- headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
-
-
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
@app.get("/geocode")
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
@@ -946,7 +874,10 @@ def _accounts_context(request: Request, error: str = "", done: str = "") -> dict
"users": users,
"catalog": features.ALL,
"screens": features.SCREENS,
- "extras": features.EXTRAS,
+ # Rozszerzenia własne PLUS wniesione przez moduły opcjonalne — inaczej
+ # administrator nie mógłby nadać uprawnienia do funkcji, która w tym
+ # obrazie istnieje.
+ "extras": features.EXTRAS + features.DOKLADANE,
"admin_login": security.app_user(),
"store_path": accounts_store.store_path(),
"error": error, "done": done,
@@ -1082,3 +1013,11 @@ def health() -> dict:
# Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
# Kubernetes i compose czytają wyłącznie kod odpowiedzi.
return {"status": "ok"}
+
+
+# ── moduły opcjonalne ──────────────────────────────────────────────────────
+# Na końcu pliku, bo moduł dostaje gotowe zależności (klienta logiki, szablony,
+# pomocnicze funkcje), a te muszą już istnieć. Bez modułu w obrazie to wywołanie
+# nie robi nic i aplikacja nie ma pojęcia, że czegoś brakuje.
+rozszerzenia.zamontuj(app, logic=logic, templates=templates,
+ buduj_utc=_build_utc, blad=_logic_error)
diff --git a/services/presentation/app/rozszerzenia.py b/services/presentation/app/rozszerzenia.py
new file mode 100644
index 0000000..17f694b
--- /dev/null
+++ b/services/presentation/app/rozszerzenia.py
@@ -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")]
diff --git a/services/presentation/app/security.py b/services/presentation/app/security.py
index 1aeca5e..d56100a 100644
--- a/services/presentation/app/security.py
+++ b/services/presentation/app/security.py
@@ -1,7 +1,7 @@
"""Ochrona dostępu do aplikacji (LOG-32).
Rdzeniem produktu są oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
-na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
+na wielu ścieżkach (`/significators`, `/interpret` i dalszych), więc BRAK
uwierzytelnienia oznacza, że każdy w sieci może je wypompować — bez udziału
jakiegokolwiek modelu językowego. Ten moduł zamyka tę drogę.
@@ -24,7 +24,7 @@ DZIENNIK AUDYTOWY (PRE-17). Każde żądanie do chronionej ścieżki zostawia wp
kto, kiedy, jaka ścieżka, status, ile rekordów (gdy handler to poda przez
`audit_records`). To jedyny sposób, by zauważyć POWOLNE wypompowywanie bazy przez
osobę uprawnioną — pojedyncze zapytanie wygląda niewinnie, suma już nie.
-Świadomie NIE logujemy treści żądań, promptów ani rekordów — logi to kolejny
+Świadomie NIE logujemy treści żądań ani rekordów — logi to kolejny
nośnik wycieku; liczby i metadane wystarczą do wykrycia nadużycia.
UWAGA: bez kont ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev). Wtedy przy
diff --git a/services/presentation/app/static/compile.js b/services/presentation/app/static/compile.js
index 4395aa2..33f8cfc 100644
--- a/services/presentation/app/static/compile.js
+++ b/services/presentation/app/static/compile.js
@@ -2,8 +2,10 @@
//
// Raport ma trzy kawałki liczone w różnych miejscach:
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
-// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
-// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
+// 2. interpretacja natalna — z zakładki Interpretacje,
+// 3. predykcje okresowe — z zakładki Kalendarz.
+// Kawałki 2 i 3 wnosi moduł opcjonalny. Gdy go nie ma, magazynów nie ma również
+// i czytanie ich zwraca pusto — raport składa się wtedy z samego horoskopu.
//
// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy.
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
@@ -86,7 +88,7 @@
}
// ── PDF (PRE-24) ────────────────────────────────────────────────────
- // Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
+ // Te kawałki mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
// do serwera, który dokłada kosmogram i woła usługę render.
function formData() {
function v(name) {
diff --git a/services/presentation/app/static/s-compile.css b/services/presentation/app/static/s-compile.css
index d0937b6..fd9e814 100644
--- a/services/presentation/app/static/s-compile.css
+++ b/services/presentation/app/static/s-compile.css
@@ -102,7 +102,7 @@ body.wheel-zoom-open { overflow: hidden; }
.readiness .rdy-missing { color: #e0c060; }
.report-head { margin: 1.5rem 0 .5rem; }
.report-head h2 { margin: 0; font-size: 1.4rem; }
-/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
+/* Bloki tekstu bywają długie — white-space:pre-wrap zachowuje akapity
bez przepisywania ich na HTML. */
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
diff --git a/services/presentation/app/static/s-interpret.css b/services/presentation/app/static/s-interpret.css
index b326ef9..af6d575 100644
--- a/services/presentation/app/static/s-interpret.css
+++ b/services/presentation/app/static/s-interpret.css
@@ -34,9 +34,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
-/* Generator promptu do LLM (LOG-29/30) */
-textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
- background: #12132a; color: var(--ink); border: 1px solid var(--line);
- border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
-textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
diff --git a/services/presentation/app/static/s-timeline.css b/services/presentation/app/static/s-timeline.css
index b4434d4..9ac6b13 100644
--- a/services/presentation/app/static/s-timeline.css
+++ b/services/presentation/app/static/s-timeline.css
@@ -31,9 +31,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
-/* Generator promptu do LLM (LOG-29/30) */
-textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
- background: #12132a; color: var(--ink); border: 1px solid var(--line);
- border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
-textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html
index 71cb211..66b2da2 100644
--- a/services/presentation/app/templates/compile.html
+++ b/services/presentation/app/templates/compile.html
@@ -3,7 +3,7 @@
{% set nav_active = "compile" %}
{% block styles %}
-{% if can(request, 'ai') %}{% endif %}
+{% include "_dodatki_compile_style.html" ignore missing %}
{% endblock %}
{% block head_scripts %}
@@ -12,19 +12,17 @@
{% endblock %}
{% block content %}
-{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI
+{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja wymieniająca
wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu
jest takim samym śladem jak przycisk. #}
-{% if can(request, 'ai') %}
-
Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
-zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.
-{% else %}
-
Składa policzony horoskop w jeden raport. Dane pobiera z pozostałych
-zakładek — nie trzeba nic przepisywać.
-{% endif %}
+{# Jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie. Wariant
+ warunkowy wymieniałby funkcję z nazwy — a zdanie opisujące funkcję jest takim
+ samym śladem jak przycisk. #}
+
Składa w jeden raport policzony horoskop i wszystkie zapamiętane
+części. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.
{# 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. #}
@@ -111,12 +107,7 @@
-{# Skrypty obsługujące WYŁĄCZNIE generowanie przez model. Bez uprawnienia nie ma
- ich nawet w odsyłaczach — sam adres pliku podpowiadałby, co program potrafi. #}
-{% if can(request, 'ai') %}
-
-
-{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
-
-{% endif %}
+{# Skrypty modułów opcjonalnych. Bez modułu w obrazie gniazdo jest puste —
+ sam adres pliku podpowiadałby, co program potrafi. #}
+{% include "_dodatki_interpret_skrypty.html" ignore missing %}
{% endblock %}
diff --git a/services/presentation/app/templates/timeline.html b/services/presentation/app/templates/timeline.html
index 145f0e5..bc1c8c7 100644
--- a/services/presentation/app/templates/timeline.html
+++ b/services/presentation/app/templates/timeline.html
@@ -3,7 +3,7 @@
{% set nav_active = "timeline" %}
{% block styles %}
-{% if can(request, 'ai') %}{% endif %}
+{% include "_dodatki_timeline_style.html" ignore missing %}
{% endblock %}
{% block head_scripts %}
@@ -47,14 +47,11 @@
- {% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
+ {% include "_dodatki_timeline_formularz.html" ignore missing %}
- {% if can(request, 'ai') %}
-
-
- {% endif %}
+ {% include "_dodatki_timeline_akcje.html" ignore missing %}
@@ -102,10 +99,5 @@
-{% if can(request, 'ai') %}
-
-
-{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
-
-{% endif %}
+{% include "_dodatki_timeline_skrypty.html" ignore missing %}
{% endblock %}
diff --git a/services/presentation/tests/skladanie.py b/services/presentation/tests/skladanie.py
new file mode 100644
index 0000000..024d8a8
--- /dev/null
+++ b/services/presentation/tests/skladanie.py
@@ -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)
diff --git a/services/presentation/tests/test_ai_tylko_w_module.py b/services/presentation/tests/test_ai_tylko_w_module.py
new file mode 100644
index 0000000..05664b2
--- /dev/null
+++ b/services/presentation/tests/test_ai_tylko_w_module.py
@@ -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"(?