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. #}
@@ -137,18 +135,9 @@ zakładek — nie trzeba nic przepisywać.

{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %} {% include "_result_tables.html" %} - {% if can(request, 'ai') %} - {# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #} -
-
- {% endif %} + {% include "_dodatki_compile_wyniki.html" ignore missing %} {% endif %} -{% if can(request, 'ai') %} -{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza — - każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #} - - -{% endif %} +{% include "_dodatki_compile_skrypty.html" ignore missing %} {% endblock %} diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html index e31765a..003970c 100644 --- a/services/presentation/app/templates/interpret.html +++ b/services/presentation/app/templates/interpret.html @@ -3,7 +3,7 @@ {% set nav_active = "interpret" %} {% block styles %} -{% if can(request, 'ai') %}{% endif %} +{% include "_dodatki_interpret_style.html" ignore missing %} {% endblock %} {% block head_scripts %} @@ -45,16 +45,12 @@ {# Cały blok generowania — budżet, dostawca, model i wynik — istnieje TYLKO dla kont z tym uprawnieniem. Nie ukrywamy go stylem: bez uprawnienia nie ma go w źródle strony, razem z katalogiem modeli, który blok wstrzykuje. #} - {% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %} -{% if can(request, 'ai') %}

{% endif %} + {% include "_dodatki_interpret_formularz.html" ignore missing %}
{% if can(request, 'export') %}{% endif %} - {% if can(request, 'ai') %} - - - {% endif %} + {% include "_dodatki_interpret_akcje.html" ignore missing %}
@@ -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"(?