Compare commits

..

1 Commits

Author SHA1 Message Date
gitea 012e4f39f6 feat(logic): aspekty pozazodiakalne — antyscja i paralele deklinacji (LOG-07)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m38s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 34s
Testy / Kontrola składni wszystkich warstw (push) Successful in 20s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m37s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 32s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 20s
Aspekty głowne (LOG-06) mierzą kąt wzdłuż ekliptyki. LOG-07 dokłada dwa rodzaje
powiązań, które klasyczna astrologia liczy naprawdę, nie na oko:

- PARALELA/KONTRPARALELA DEKLINACJI — dwa ciała na tej samej („parallel") lub
  przeciwnej („contraparallel") deklinacji działają jak koniunkcja/opozycja,
  mimo że wzdłuż ekliptyki mogą stać gdziekolwiek. Deklinacja liczona PEŁNYM
  wzorem (z szerokością ekliptyczną — istotne dla Księżyca i planet schodzących
  z ekliptyki), przez to_equatorial z LOG-04. Orb konfigurowalny (domyślnie 1°,
  ciasno — to kontakty punktowe).
- ANTYSCJA/KONTRANTYSCJA — odbicie długości względem osi przesileń (0° Raka –
  0° Koziorożca) albo równonocy (0° Barana – 0° Wagi).

Liczone na współrzędnych TROPIKALNYCH of-date, bo deklinacja jest fizyczna
(równikowa, niezależna od zodiaku), a antyscja z definicji tropikalna — jej oś to
kardynalne punkty zodiaku tropikalnego. Dlatego PRZED przesunięciem na zodiak
syderyczny/draconiczny.

Dodatkowo: flaga „out of bounds" (|deklinacja| > nachylenie ekliptyki — ciało
poza zakresem Słońca) i kolumna deklinacji przy każdej pozycji.

Integracja z bazą: baza interpretacyjna ZNA paralele pod frazą „P. Dec.", więc
generujemy fasetkę paraleli (trafia też do promptu LLM). PUŁAPKA: samo „Dec." w
bazie to często DEKANAT („3rd Dec. of [Gem"), więc szukamy dokładnie „P. Dec." —
inaczej sypnęłoby fałszywymi trafieniami. Kontrparaleli i antyscji baza nie
opisuje osobnym znacznikiem, więc zostają obliczeniem display-only.

Pary z RIGID_PAIRS (węzły) odsiane: SN = NN+180° na ekliptyce → deklinacja
ZAWSZE przeciwna, czyli definicyjna kontrparalela bez informacji.

Walidacja wobec faktów NIEZALEŻNYCH od kodu:
- deklinacja Słońca 30.04.1984 = +14.88° wobec ~+14.9° z almanachu,
- antyscja to arytmetyka odbicia: inwolucja i pary znaków (Rak↔Bliźnięta,
  Baran↔Panna) sprawdzone na piechotę,
- węzły faktycznie mają przeciwną deklinację i są odsiane,
- deklinacja niezależna od zodiaku (tropikalny == syderyczny co do 1e-6°),
- realna baza: mechanizm paraleli znajduje wpisy „P. Dec." i odsiewa dekanaty
  (z 33 wpisów „P. Dec." 5 ma niepusty efekt — głównie „[conj or P. Dec.").

UI: kolumna deklinacji (+znacznik OOB), tabela paraleli/kontrparaleli i tabela
antyscji na ekranie Horoskop.

Testy: 15 nowych (out_of_zodiac) + 2 (fasetka paraleli vs pułapka dekanatu).
Całość: logika 251 passed / 1 skipped, prezentacja 25 passed. Render szablonu
sprawdzony osobno (bez błędu Jinja, wszystkie sekcje obecne).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:20:02 +02:00
42 changed files with 19 additions and 3537 deletions
Binary file not shown.
-139
View File
@@ -1,139 +0,0 @@
# Wdrożenie usługi `render` — raport PDF (PRE-24)
Instrukcja krok po kroku dla nowego komponentu. Dotyczy dwóch repozytoriów:
| Repo | Co wnosi |
|---|---|
| `gitea/astrololo` | usługa `services/render`, klient w prezentacji, przycisk „Pobierz PDF", wariant kosmogramu do druku |
| `gitea/deploy` | `astrololo/render.yaml`, adres i klucz łącza w `presentation.yaml` |
---
## Dlaczego osobna usługa
TeX Live waży setki megabajtów. W obrazie prezentacji spowalniałby każdy build
i deploy, a przy każdej poprawce w CSS trzeba by go ciągnąć od nowa. Osobno:
obraz produktu zostaje mały, TeX aktualizuje się niezależnie, a **awaria renderu
nie kładzie aplikacji** — przestaje działać wyłącznie przycisk „Pobierz PDF".
To ta sama zasada, co przy izolacji silnika swisseph (LOG-27).
---
## Krok 1 — klucz łącza (PRZED wdrożeniem)
Usługa dostaje **cały raport**: dane urodzeniowe i opisy z baz. Łącze jest
szyfrowane AES-256-GCM, własnym, **trzecim** kluczem — przejęcie go nie może
otwierać łącza do logiki ani do danych.
Sekret `astrololo-link` już istnieje (PRE-16); dokładamy do niego trzeci klucz,
**zachowując dwa dotychczasowe**:
```bash
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link \
-o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link \
-o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
```
Sprawdzenie — mają być **trzy** klucze:
```bash
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
```
> **Uwaga:** `LINK_ENCRYPTION_REQUIRED=true` działa fail-closed. Bez klucza pod
> `render` **nie wstanie** — i tak ma być. Usługa, która wstała i po cichu nie
> szyfruje, jest gorsza niż CrashLoop, bo awarii nie widać.
---
## Krok 2 — obraz usługi
Obraz budowany jest osobnym workflow (jak silnik swisseph — nie wchodzi do
głównego pipeline'u produktu). Pierwszy build trwa dłużej, bo ciągnie TeX Live.
```bash
kubectl -n astrololo get deploy render -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
```
Pakiety w obrazie dobrane **wąsko** (nie `texlive-full`, który ma kilka GB):
| Pakiet | Po co |
|---|---|
| `texlive-xetex` | silnik **XeLaTeX** — konieczny, bo raport ma polskie znaki i glify astrologiczne; `pdflatex` ich nie złoży |
| `texlive-latex-recommended` | `geometry`, `graphicx` |
| `fonts-dejavu-core` | jeden font na polskie znaki **i** symbole (♄ ♓ ☉) |
| `librsvg2-bin` | `rsvg-convert` — SVG kosmogramu → PDF (LaTeX nie wstawia SVG wprost) |
Dockerfile sprawdza obecność obu narzędzi **przy budowie**, więc zepsuty obraz
nie dojedzie na produkcję niezauważony.
---
## Krok 3 — merge manifestów
```bash
tea pr merge --login gitea --repo gitea/deploy <numer>
kubectl -n astrololo rollout status deploy/render deploy/presentation
```
Wjeżdża naraz: `Deployment` + `Service` renderu (ClusterIP — **bez** Ingressu
i NodePortu, nie ma powodu sięgać do niej z zewnątrz) oraz `RENDER_URL`
i trzeci klucz w prezentacji.
---
## Krok 4 — sprawdzenie
### Narzędzia są na miejscu
`/health` raportuje obecność `xelatex` i `rsvg-convert`, żeby zepsuty obraz
było widać od razu, a nie dopiero przy pierwszym raporcie:
```bash
kubectl -n astrololo exec deploy/presentation -- \
python -c "import httpx; print(httpx.get('http://render:8004/health').text)"
```
Oczekiwane: `{"status":"ok","layer":"render","tools":{"xelatex":true,"rsvg-convert":true}}`.
Gdy `status` to `degraded` — obraz zbudował się bez któregoś narzędzia.
### Łącze faktycznie szyfruje
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **z poprawnym tokenem,
ale bez szyfrowania**:
```bash
kubectl -n astrololo exec deploy/presentation -- python -c "
import httpx, os
r = httpx.post('http://render:8004/pdf', json={'person':'test'},
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
print(r.status_code, r.text[:80])
"
```
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Sam token już
nie wystarcza — o to chodziło.
### Raport od końca do końca
W przeglądarce: **Skompiluj** → wypełnij dane → *Złóż podsumowanie**Pobierz PDF*.
PDF ma zacząć się od imienia i nazwiska, potem wprowadzone dane, **rysunek
kosmogramu**, a po nim interpretacja natalna i predykcje okresowe.
---
## Czego ta wersja nie załatwia
- **Kompilacja PDF nie została sprawdzona end-to-end** w środowisku, w którym
powstawała — nie było tam ani TeX Live, ani runtime'u kontenerów. Sprawdzone
jest wszystko dookoła: generowanie źródła `.tex` (w tym ucieczka znaków
specjalnych), szyfrowanie łącza, kontrakt API i samowystarczalność SVG.
**Pierwsze uruchomienie na klastrze trzeba obejrzeć.**
- **Dzielenie wyrazów** jest angielskie — nie wciągamy `polyglossia`, żeby nie
puchł obraz. Tekst składa się poprawnie, tylko przenoszenie bywa nieoptymalne.
- **Brak kolejki**. Długi raport blokuje jedno połączenie na czas kompilacji
(timeout klienta: 180 s). Przy większym ruchu warto dołożyć zadania w tle.
- **Glify w tekście od modelu**. Kosmogram idzie jako obraz (rysuje go
`rsvg-convert` z DejaVu), ale gdyby model wplótł symbole w prozę, złoży je
XeLaTeX — też z DejaVu. Jeśli któregoś zabraknie, LaTeX zgłasza
„Missing character" w logu i **nie drukuje znaku**; warto zerknąć w log
pierwszego raportu.
-4
View File
@@ -65,10 +65,6 @@ CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res" REQUEST, RESPONSE = b"req", b"res"
+2 -20
View File
@@ -36,7 +36,6 @@ def _shift_pos(pdict: dict, off: float) -> None:
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN, def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict: lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
from app.engine import glyphs as GL
from app.engine.aspects import find_aspects from app.engine.aspects import find_aspects
from app.engine.houses import mean_obliquity from app.engine.houses import mean_obliquity
from app.engine.out_of_zodiac import ( from app.engine.out_of_zodiac import (
@@ -65,10 +64,6 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
result["parallels"] = find_declination_aspects(result["positions"]) result["parallels"] = find_declination_aspects(result["positions"])
result["antiscia"] = find_antiscia(result["positions"]) result["antiscia"] = find_antiscia(result["positions"])
# glify aspektów (LOG-22) — symbol aspektu nie zależy od zodiaku
for a in result["aspects"]:
a["glyph"] = GL.aspect_glyph(a["aspect"])
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła # offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None) node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon) off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
@@ -78,13 +73,6 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
for pdict in result["positions"]: for pdict in result["positions"]:
_shift_pos(pdict, off) _shift_pos(pdict, off)
# glify obiektów (LOG-22): symbol planety jest niezmienniczy, symbol ZNAKU
# zależy od zodiaku, więc po przesunięciu. Pierścień 12 znaków pod kosmogram.
for pdict in result["positions"]:
pdict["glyph"] = GL.glyph_for(pdict["name"])
pdict["sign_glyph"] = GL.sign_glyph(pdict["sign"])
result["sign_glyphs"] = [{"sign": s, "glyph": GL.sign_glyph(s)} for s in SIGNS]
if not hasattr(engine, "sidereal"): if not hasattr(engine, "sidereal"):
return result return result
@@ -101,13 +89,9 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off), "Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
"IC": _fmt("IC", norm360(mc + 180.0), off), "IC": _fmt("IC", norm360(mc + 180.0), off),
} }
for a in result["angles"].values(): # glif znaku osi (LOG-22)
a["sign_glyph"] = GL.sign_glyph(a["sign"])
result["cusps"] = [ result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))], {"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off)), "in_sign": in_sign(norm360(c - off))}
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
for i, c in enumerate(cusp_list) for i, c in enumerate(cusp_list)
] ]
for pdict, obj in zip(result["positions"], positions): for pdict, obj in zip(result["positions"], positions):
@@ -126,9 +110,7 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku "longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))], "sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
"in_sign": in_sign(norm360(lot["longitude"] - off)), "in_sign": in_sign(norm360(lot["longitude"] - off)),
"house": H.assign_house(lot["longitude"], cusp_list), # dom po długości tropikalnej "house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
"glyph": GL.glyph_for(lot["name"]), # ⊗ dla Fortuny; reszta None (LOG-22)
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(lot["longitude"] - off))])}
for lot in compute_lots(pts, day, lots_method) for lot in compute_lots(pts, day, lots_method)
] ]
return result return result
-177
View File
@@ -1,177 +0,0 @@
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ☌ ⊗). Potrzebne
pod rysowanie kosmogramu (PRE-12) — planety i znaki na kole rysujemy symbolami.
DWIE zasady z bazy wymagań, obie krytyczne:
* DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, ♀, ♂) ma
domyślnie prezentację emoji — kolorowy kwadrat zamiast czarno-białego glifu,
nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową
selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie).
* DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symbol→tekst)
normalizuje wejście, zdejmując selektory wariantu, żeby glif z FE0E i bez
dawał ten sam wynik.
Glify zapisane przez \\u — jednoznaczne code-pointy, odporne na zniekształcenia
edytorów i samodokumentujące (widać, który to znak Unicode).
"""
from __future__ import annotations
# selektory wariantu prezentacji
_TEXT = "" # VS15 — wymusza glif tekstowy (czarno-biały)
_EMOJI = "" # VS16 — prezentacja emoji; NIGDY nie emitujemy, zdejmujemy przy reverse
def _t(cp: str) -> str:
"""Znak z wymuszoną prezentacją tekstową (dokleja VS15)."""
return cp + _TEXT
# ── planety i światła ────────────────────────────────────────────────────
# ♀ i ♂ mają wariant emoji → wymuszamy tekst; reszta jest tekstowa domyślnie.
PLANET = {
"Sun": "", # ☉
"Moon": "", # ☽
"Mercury": "", # ☿
"Venus": _t(""), # ♀︎
"Mars": _t(""), # ♂︎
"Jupiter": "", # ♃
"Saturn": "", # ♄
"Uranus": "", # ♅
"Neptune": "", # ♆
"Pluto": "", # ♇
}
# ── punkty wirtualne ─────────────────────────────────────────────────────
POINT = {
"North Node": "", # ☊
"South Node": "", # ☋
"Lilith": "", # ⚸ (Black Moon Lilith)
"Chiron": "", # ⚷
}
# ── Lots (punkty arabskie) ───────────────────────────────────────────────
# Standardowy glif ma tylko Fortuna (⊗). Reszta Lotów nie ma powszechnie
# wspieranego symbolu → zwracamy None i UI pokazuje nazwę.
LOT = {
"Fortune": "", # ⊗
"Part of Fortune": "",
}
# ── znaki zodiaku (wszystkie mają wariant emoji → wszystkie z VS15) ──────
_SIGN_CP = {
"Aries": "", "Taurus": "", "Gemini": "", "Cancer": "",
"Leo": "", "Virgo": "", "Libra": "", "Scorpio": "",
"Sagittarius": "", "Capricorn": "", "Aquarius": "", "Pisces": "",
}
SIGN = {name: _t(cp) for name, cp in _SIGN_CP.items()}
# ── aspekty (nazwy jak w aspects.py + minor z abbreviations) ─────────────
ASPECT = {
"conjunction": "", # ☌
"opposition": "", # ☍
"trine": "", # △
"square": "", # □
"sextile": "", # ⚹
"semisextile": "", # ⚺
"quincunx": "", # ⚻
"semisquare": "", # ∠
}
# ── ruch ─────────────────────────────────────────────────────────────────
RETROGRADE = "" # ℞
DIRECT = "D" # zwykłe „D" — brak dedykowanego glifu prostego ruchu
# ── forward: nazwa → glif ────────────────────────────────────────────────
def glyph_for(name: str) -> str | None:
"""Glif obiektu/punktu/Lota po nazwie (jak w silniku). None, gdy brak."""
return PLANET.get(name) or POINT.get(name) or LOT.get(name)
def sign_glyph(sign: str) -> str | None:
return SIGN.get(sign)
def aspect_glyph(aspect: str) -> str | None:
return ASPECT.get(aspect)
# ── reverse: glif → nazwa ────────────────────────────────────────────────
def _strip_variants(g: str) -> str:
"""Zdejmuje selektory wariantu — glif z FE0E i bez daje ten sam klucz."""
return g.replace(_TEXT, "").replace(_EMOJI, "")
def _reverse(mapping: dict[str, str]) -> dict[str, str]:
# pierwsze wystąpienie wygrywa (Fortune vs Part of Fortune → 'Fortune')
out: dict[str, str] = {}
for name, g in mapping.items():
out.setdefault(_strip_variants(g), name)
return out
_PLANET_POINT_REV = _reverse({**PLANET, **POINT, **{"Fortune": LOT["Fortune"]}})
_SIGN_REV = _reverse(SIGN)
_ASPECT_REV = _reverse(ASPECT)
def name_for_glyph(g: str) -> str | None:
"""Obiekt/punkt po glifie (znosi obecność lub brak selektora wariantu)."""
return _PLANET_POINT_REV.get(_strip_variants(g))
def sign_for_glyph(g: str) -> str | None:
return _SIGN_REV.get(_strip_variants(g))
def aspect_for_glyph(g: str) -> str | None:
return _ASPECT_REV.get(_strip_variants(g))
# ── glifikacja tekstu sygnifikatora (składnia [XX z bazy) ────────────────
# Nasze sygnifikatory zapisane są tokenami z prefiksem [ (np. „[Sa [conj [PF").
# glyphify zamienia znane tokeny na glify, resztę zostawia. To druga strona
# abbreviations.expand: tam token → słowo, tu token → symbol.
import re as _re
# token po nawiasie (np. „Su", „Tau", „conj", „PF") → glif
_TOKEN_GLYPH: dict[str, str] = {}
_ABBR_TO_NAME = {
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus", "Ma": "Mars",
"Ju": "Jupiter", "Sa": "Saturn", "Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
"PF": "Fortune", "Fortune": "Fortune",
}
for _abbr, _name in _ABBR_TO_NAME.items():
_g = glyph_for(_name)
if _g:
_TOKEN_GLYPH[_abbr] = _g
_SIGN_ABBR_NAME = {
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer", "Leo": "Leo",
"Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio", "Sag": "Sagittarius",
"Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
}
for _abbr, _name in _SIGN_ABBR_NAME.items():
_TOKEN_GLYPH[_abbr] = SIGN[_name]
_ASP_ABBR_NAME = {
"conj": "conjunction", "opp": "opposition", "tri": "trine", "sq": "square",
"sex": "sextile", "semisex": "semisextile", "quincunx": "quincunx", "semisq": "semisquare",
}
for _abbr, _name in _ASP_ABBR_NAME.items():
if _name in ASPECT:
_TOKEN_GLYPH[_abbr] = ASPECT[_name]
_TOKEN_RE = _re.compile(r"\[([A-Za-z]+)")
def glyphify(text: str) -> str:
"""Zamienia tokeny [XX na glify; nieznane zostawia bez zmiany. Dodatkowo
»Rx«/»R« → ℞. Nie parsuje stopni — te i tak są czytelne (26°08')."""
if not text:
return text
out = _TOKEN_RE.sub(lambda m: _TOKEN_GLYPH.get(m.group(1), m.group(0)), text)
out = _re.sub(r"\bR[x]?\b", RETROGRADE, out)
return out
-4
View File
@@ -65,10 +65,6 @@ CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res" REQUEST, RESPONSE = b"req", b"res"
-114
View File
@@ -1,114 +0,0 @@
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
Sedno: glify muszą być KOMPLETNE (każdy obiekt/znak/aspekt, który liczymy, ma
symbol), DWUKIERUNKOWE (symbol → tekst z powrotem) i TEKSTOWE, nigdy emoji
(DAN-18) — inaczej na kosmogramie wyjdą kolorowe kwadraty zamiast czarno-białych
symboli.
"""
import pytest
from app.engine import glyphs as G
from app.engine.aspects import MAJOR
from app.engine.formats import SIGNS
from app.engine.models import DEFAULT_OBJECTS
VS15, VS16 = "", ""
def _all_glyphs():
return (list(G.PLANET.values()) + list(G.POINT.values()) + list(G.LOT.values())
+ list(G.SIGN.values()) + list(G.ASPECT.values()) + [G.RETROGRADE])
# ------------------------------------------------------------- kompletność
def test_every_default_object_has_a_glyph():
"""Każdy obiekt z kanonicznego zestawu musi mieć symbol — inaczej na kole
zostanie dziura."""
missing = [name for name in DEFAULT_OBJECTS if G.glyph_for(name) is None]
assert not missing, f"obiekty bez glifu: {missing}"
def test_every_sign_has_a_glyph():
missing = [s for s in SIGNS if G.sign_glyph(s) is None]
assert not missing, f"znaki bez glifu: {missing}"
def test_every_major_aspect_has_a_glyph():
missing = [a for a in MAJOR if G.aspect_glyph(a) is None]
assert not missing, f"aspekty główne bez glifu: {missing}"
def test_fortune_and_retrograde_from_requirement():
"""Wymóg wprost wymienia ⊗ (Fortuna) i ℞ (retrogradacja)."""
assert G.glyph_for("Fortune") == ""
assert G.RETROGRADE == ""
# ------------------------------------------------------ DAN-18: tekst, nie emoji
def test_no_glyph_uses_emoji_variation_selector():
"""VS16 (U+FE0F) wymusiłby prezentację emoji — nie może go być NIGDZIE."""
offenders = [g for g in _all_glyphs() if VS16 in g]
assert not offenders, f"glify z wariantem emoji (FE0F): {offenders!r}"
def test_signs_force_text_presentation():
"""Znaki zodiaku mają domyślnie wariant emoji → MUSZĄ nieść VS15."""
for s in SIGNS:
assert VS15 in G.sign_glyph(s), f"{s} bez wymuszenia tekstu (VS15)"
def test_venus_mars_force_text_but_sun_does_not():
"""♀/♂ mają wariant emoji (VS15 konieczny); ☉ nie ma (VS15 zbędny)."""
assert VS15 in G.glyph_for("Venus")
assert VS15 in G.glyph_for("Mars")
assert VS15 not in G.glyph_for("Sun") # bez zaśmiecania niepotrzebnym selektorem
# ---------------------------------------------------------- dwukierunkowość
def test_object_round_trip():
for name in DEFAULT_OBJECTS:
assert G.name_for_glyph(G.glyph_for(name)) == name
def test_sign_round_trip():
for s in SIGNS:
assert G.sign_for_glyph(G.sign_glyph(s)) == s
def test_aspect_round_trip():
for a in MAJOR:
assert G.aspect_for_glyph(G.aspect_glyph(a)) == a
def test_reverse_tolerates_missing_variant_selector():
"""Symbol wklejony bez VS15 też musi się rozpoznać — bo z zewnątrz przyjdzie
dowolna forma (DAN-17: łańcuch znosi każdy Unicode)."""
assert G.sign_for_glyph("") == "Pisces" # ♓ bez VS15
assert G.sign_for_glyph("" + VS15) == "Pisces" # ♓ z VS15
assert G.name_for_glyph("") == "Venus" # ♀ bez VS15
# --------------------------------------------------------- glifikacja tekstu
def test_glyphify_matches_requirement_example():
"""Przykład z wymagań: [Sa [Pis 26°08' [conj [PF -> ♄ ♓ 26°08' ☌ ⊗."""
out = G.glyphify("[Sa [Pis 26°08' [conj [PF")
assert G.glyph_for("Saturn") in out
assert G.sign_glyph("Pisces") in out
assert G.aspect_glyph("conjunction") in out
assert G.glyph_for("Fortune") in out
assert "26°08'" in out # stopnie nietknięte
def test_glyphify_handles_retrograde_and_leaves_unknown():
out = G.glyphify("[Ma Rx w [Xyz")
assert G.glyph_for("Mars") in out
assert G.RETROGRADE in out
assert "[Xyz" in out # nieznany token bez zmian
def test_glyphify_empty_is_safe():
assert G.glyphify("") == ""
+3 -3
View File
@@ -342,13 +342,13 @@ def test_incremental_unframing_handles_split_frames(link):
# ----------------------------------------------- trzy kopie muszą być identyczne # ----------------------------------------------- trzy kopie muszą być identyczne
def test_all_services_share_the_same_module(): def test_all_three_services_share_the_same_module():
"""Moduł jest skopiowany do KAŻDEJ usługi, która szyfruje łącze (nie mają wspólnej """Moduł jest skopiowany do trzech niezależnych usług (nie mają wspólnej
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
„nie da się odszyfrować" — więc pilnujemy tego testem.""" „nie da się odszyfrować" — więc pilnujemy tego testem."""
root = pathlib.Path(__file__).resolve().parents[3] root = pathlib.Path(__file__).resolve().parents[3]
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py") copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
for svc in ("presentation", "logic", "data", "render")} for svc in ("presentation", "logic", "data")}
missing = [svc for svc, path in copies.items() if not path.is_file()] missing = [svc for svc, path in copies.items() if not path.is_file()]
assert not missing, f"brak modułu w warstwach: {missing}" assert not missing, f"brak modułu w warstwach: {missing}"
contents = {svc: path.read_bytes() for svc, path in copies.items()} contents = {svc: path.read_bytes() for svc, path in copies.items()}
-255
View File
@@ -1,255 +0,0 @@
"""Kosmogram — koło horoskopowe jako SVG (PRE-12).
Rysujemy po stronie serwera, bez zależności zewnętrznych (spójne z CSP): czysty
string SVG wstawiany do strony. Gotowe: pierścień znaków, podział na domy, osie
oraz OBIEKTY (glify, stopień w znaku, retrogradacja). Linie aspektów w kolejnym
kroku.
Geometria (konwencja astrologiczna):
* Ascendent po LEWEJ (godzina 9), długość ekliptyczna rośnie PRZECIWNIE do
ruchu wskazówek zegara — jak na niebie i w każdym programie.
* Punkt o długości λ trafia na kąt φ = 180° Asc). Zakotwiczenie:
λ=Asc → lewo, λ=Asc+90° → dół, λ=Asc+270° (≈MC) → góra. W SVG y rośnie w dół,
co ta formuła już uwzględnia (sin dodatni = w dół).
Dane bierzemy z wyniku /chart/positions (warstwa logiki): `angles` (z długością
`decimal`), `cusps` (z `decimal` — dołożone pod ten widok) i `sign_glyphs`
(pierścień 12 znaków z glifami — LOG-22). Prezentacja niczego nie liczy sama.
"""
from __future__ import annotations
import math
from html import escape
# ── promienie (viewBox 470×470, środek 235,235) ─────────────────────────
# Płótno jest większe od koła CELOWO: margines poza obwodem mieści etykiety osi
# (AC/DC/MC/IC). Wcześniej siedziały wewnątrz, w pierścieniu obiektów, i zasłaniały
# glify w ciasnych skupiskach — widać było na renderze.
_CX = _CY = 235.0
_VIEW = 470
R_OUT = 210.0 # zewnętrzny okrąg
R_AXIS_LABEL = 222.0 # etykiety osi — w marginesie, poza kołem
R_ZOD = 178.0 # wewnętrzna krawędź pasa znaków
R_SIGN = 194.0 # promień glifów znaków (środek pasa)
R_TICK = 172.0 # koniec drobnych podziałek stopni (do wewnątrz od pasa)
R_HUB = 48.0 # centralne kółko — tu kończą się szprychy domów i osie
R_HNUM = 64.0 # promień numerów domów (tuż przy piaście)
R_PLANET = 150.0 # promień glifów obiektów
R_DEG = 130.0 # promień podpisu stopnia (pod glifem)
R_MARK = R_ZOD - 7.0 # koniec kreski wskazującej PRAWDZIWĄ pozycję na pasie
# Minimalny odstęp kątowy między glifami obiektów. O ciasnocie decyduje NIE glif,
# tylko podpis stopnia — leży bliżej środka (r=130), gdzie ten sam kąt to mniej
# pikseli: 8° daje tam ~18 px, a dwuznakowy podpis z ℞ zajmuje ~16 px. Przy 7°
# (pierwsze podejście) podpisy w skupiskach zlewały się — widać było na renderze.
# Bez rozsuwania w ogóle ciasne układy (u nas Merkury i Wenus 0,4°!) rysują się
# jeden na drugim.
MIN_SEP = 8.0
# muted kolory żywiołów — czytelne na ciemnym tle, spójne z paletą aplikacji
_ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia, powietrze, woda
# ── motywy ───────────────────────────────────────────────────────────────
# „screen" korzysta ze zmiennych CSS strony, więc koło samo dostraja się do
# motywu aplikacji. „print" MUSI mieć konkretne kolory: SVG idzie wtedy do
# osobnego konwertera (rsvg → PDF, PRE-24), który o naszym arkuszu nic nie wie
# i zmiennych `var(--…)` nie rozwiąże — wyszłyby czarne albo żadne kreski.
# Przy okazji odwracamy kontrast: raport drukuje się na białym papierze.
_THEMES = {
"screen": {"line": "var(--line)", "ink": "var(--ink)", "muted": "var(--muted)",
"accent": "var(--accent)", "disc": "#12132a", "retro": "#ff9b6a",
"elements": _ELEMENT_COLOR},
"print": {"line": "#b9bcc9", "ink": "#111318", "muted": "#5d6270",
"accent": "#5a49c8", "disc": "#ffffff", "retro": "#b2452a",
# ciemniejsze warianty żywiołów — na białym tle jasne byłyby niewidoczne
"elements": ["#a8442a", "#3f6b33", "#8a6d10", "#1f5c80"]},
}
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
phi = math.radians(180.0 - (lon - asc))
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.0,
opacity: float = 1.0) -> str:
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"/>')
# Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS
# `.glyph`, ale w wariancie do druku SVG jedzie do SAMODZIELNEGO konwertera, który
# naszego arkusza nie widzi — wtedy trzeba wpisać font wprost do elementu, inaczej
# rysunek wychodzi BEZ symboli (konwerter bierze domyślny font, a ten ich nie ma).
GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, "
"DejaVu Sans, sans-serif")
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "",
font: str = "") -> str:
c = f' class="{cls}"' if cls else ""
f = f' font-family="{font}"' if font else ""
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
f' text-anchor="middle" dominant-baseline="central"{c}{f}>{escape(s)}</text>')
def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]:
"""Rozsuwa kąty tak, by sąsiedzi mieli co najmniej `min_sep`, zachowując
KOLEJNOŚĆ obiektów na kole i trzymając je blisko prawdziwych pozycji.
Metoda: relaksacja po okręgu — każdą zbyt ciasną parę rozpychamy po połowie
brakującego odstępu i powtarzamy, aż nic się nie rusza. Symetryczne pchnięcie
sprawia, że skupisko rozkłada się wokół swojego środka, zamiast dryfować
w jedną stronę.
Gdy obiektów jest tyle, że nie da się ich rozsunąć (n·min_sep ≥ 360°),
rozkładamy je równomiernie — lepiej równo niż w nieskończonej pętli.
"""
n = len(lons)
if n < 2:
return list(lons)
order = sorted(range(n), key=lambda i: lons[i] % 360.0)
a = [lons[i] % 360.0 for i in order]
if n * min_sep >= 360.0:
a = [(a[0] + i * 360.0 / n) % 360.0 for i in range(n)]
else:
# ROZWIJAMY kąty do osi monotonicznej. To nie kosmetyka: gdy liczyliśmy
# odstęp modulo 360, przesunięcie, które przerzuciło obiekt ZA sąsiada,
# dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że jest luzem,
# i kończył z dwoma glifami na sobie (Wenus lądowała na Księżycu).
# Na osi rozwiniętej ujemna luka zostaje ujemna i zawsze się ją wyłapie.
for i in range(1, n):
while a[i] < a[i - 1]:
a[i] += 360.0
for _ in range(passes):
moved = False
for i in range(n - 1):
gap = a[i + 1] - a[i]
if gap < min_sep - 1e-9:
push = (min_sep - gap) / 2.0
a[i] -= push
a[i + 1] += push
moved = True
closing = (a[0] + 360.0) - a[n - 1] # domknięcie okręgu
if closing < min_sep - 1e-9:
push = (min_sep - closing) / 2.0
a[n - 1] -= push
a[0] += push
moved = True
if not moved:
break
out = [0.0] * n
for slot, original in enumerate(order):
out[original] = a[slot] % 360.0
return out
def _delta(a: float, b: float) -> float:
"""Najmniejsza różnica kątów w [0,180]."""
return abs(((a - b + 180.0) % 360.0) - 180.0)
def render(chart: dict, theme: str = "screen") -> str:
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
"""
T = _THEMES.get(theme, _THEMES["screen"])
# tylko dla druku — na stronie font podaje CSS (.glyph)
glyph_font = GLYPH_FONT if theme == "print" else ""
angles = chart.get("angles")
cusps = chart.get("cusps")
signs = chart.get("sign_glyphs")
if not (angles and cusps and signs) or "decimal" not in (cusps[0] if cusps else {}):
return ""
asc = float(angles["Asc"]["decimal"])
parts: list[str] = []
# tło i okręgi
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_OUT}" fill="{T["disc"]}" stroke="{T["line"]}" stroke-width="1.5"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_ZOD}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_HUB}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
for deg in range(0, 360, 5):
r_in = R_TICK if deg % 10 else R_TICK - 4
x1, y1 = _pt(deg, asc, R_ZOD)
x2, y2 = _pt(deg, asc, r_in)
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
for i, sg in enumerate(signs):
b = 30.0 * i
x1, y1 = _pt(b, asc, R_ZOD)
x2, y2 = _pt(b, asc, R_OUT)
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
gx, gy = _pt(b + 15.0, asc, R_SIGN)
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
# szprychy domów (od pasa do piasty) + numery domów w środku każdego domu
n = len(cusps)
for i, c in enumerate(cusps):
lon = float(c["decimal"])
x1, y1 = _pt(lon, asc, R_ZOD)
x2, y2 = _pt(lon, asc, R_HUB)
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
nxt = float(cusps[(i + 1) % n]["decimal"])
span = (nxt - lon) % 360.0 or 360.0
mid = lon + span / 2.0
hx, hy = _pt(mid, asc, R_HNUM)
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
# osie: AscDsc i MCIC przez całe koło, wyróżnione akcentem
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
lon_a = float(angles[a]["decimal"])
ax, ay = _pt(lon_a, asc, R_ZOD)
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
# ── obiekty: glify na pozycjach, stopień w znaku, retrogradacja ─────
# Glify rozsuwamy, ale kreska na pasie zostaje w PRAWDZIWEJ pozycji —
# inaczej wykres kłamałby o tym, gdzie planeta faktycznie stoi. Gdy glif
# jest przesunięty, łączymy go z kreską cienką linią odniesienia.
objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
true_lons = [float(p["decimal"]) for p in objects]
draw_lons = spread(true_lons)
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
retro = obj.get("direction") == "Rx"
mx1, my1 = _pt(true_lon, asc, R_ZOD)
mx2, my2 = _pt(true_lon, asc, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
if _delta(draw_lon, true_lon) > 0.4:
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
gx, gy = _pt(draw_lon, asc, R_PLANET)
parts.append(_text(gx, gy, obj.get("glyph") or "", size=16,
fill=T["ink"], cls="glyph", font=glyph_font))
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
dx, dy = _pt(draw_lon, asc, R_DEG)
label = f"{int(true_lon % 30)}" + ("" if retro else "")
parts.append(_text(dx, dy, label, size=8,
fill=T["retro"] if retro else T["muted"]))
body = "".join(parts)
return (f'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
f'aria-label="Kosmogram — koło horoskopowe" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
@@ -1,52 +0,0 @@
"""Klient HTTP do usługi render (PRE-24).
Jedyny punkt styku prezentacji z usługą składającą PDF. Idzie po szyfrowanym
łączu, tak jak reszta ruchu wewnątrz klastra — z WŁASNYM, trzecim kluczem
(`LINK_KEY_PRESENTATION_RENDER`). Osobny klucz, bo przez to łącze leci CAŁY
raport: dane urodzeniowe i opisy z baz. Przejęcie go nie może otwierać łącza do
logiki ani do danych.
"""
from __future__ import annotations
import os
import httpx
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza prezentacja↔render. Czytany przy każdym wywołaniu, bo
konfiguracja może się zmienić bez restartu procesu."""
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_RENDER)
return link_crypto.Link(key) if key else None
class RenderClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.render_url).rstrip("/")
def pdf(self, report: dict) -> bytes:
"""Raport → bajty PDF. Timeout długi: XeLaTeX na obszernym tekście
potrzebuje chwili, a raport bywa na kilkadziesiąt stron."""
with httpx.Client(timeout=max(settings.http_timeout, 180.0)) as client:
return link_crypto.call(client, "POST", f"{self.base_url}/pdf",
payload=report, headers=_auth_headers(),
link=_link())
def health(self) -> dict:
"""/health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
nie mają, a nie przechodzi tędy nic z raportu."""
import json
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
r.raise_for_status()
return json.loads(r.content)
-2
View File
@@ -11,8 +11,6 @@ from dataclasses import dataclass, field
@dataclass @dataclass
class Settings: class Settings:
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001")) logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
# Usługa składająca PDF (PRE-24) — osobny komponent, bo dźwiga TeX Live.
render_url: str = field(default_factory=lambda: os.getenv("RENDER_URL", "http://localhost:8004"))
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10"))) http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
-4
View File
@@ -65,10 +65,6 @@ CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res" REQUEST, RESPONSE = b"req", b"res"
+3 -114
View File
@@ -71,7 +71,6 @@ def chart_form(request: Request):
@app.post("/", response_class=HTMLResponse) @app.post("/", response_class=HTMLResponse)
def chart_compute( def chart_compute(
request: Request, request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...), date: str = Form(...),
time: str = Form(...), time: str = Form(...),
tz_offset: float = Form(0.0), tz_offset: float = Form(0.0),
@@ -82,7 +81,7 @@ def chart_compute(
zodiac: str = Form("tropical"), zodiac: str = Form("tropical"),
tables: bool = Form(False), tables: bool = Form(False),
): ):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations, "lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
"zodiac": zodiac, "tables": tables} "zodiac": zodiac, "tables": tables}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None} ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
@@ -93,8 +92,6 @@ def chart_compute(
when_utc_iso=iso_utc, lat=lat, lon=lon, when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables, house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
) )
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
except (httpx.HTTPError,) as e: except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e) ctx["error"] = _logic_error(e)
except ValueError as e: except ValueError as e:
@@ -102,112 +99,6 @@ def chart_compute(
return templates.TemplateResponse(request, "chart.html", ctx) return templates.TemplateResponse(request, "chart.html", ctx)
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
@app.get("/compile", response_class=HTMLResponse)
def compile_form(request: Request):
return templates.TemplateResponse(
request, "compile.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/compile", response_class=HTMLResponse)
def compile_build(
request: Request,
person: str = Form(""),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
zodiac: str = Form("tropical"),
):
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
Dlaczego horoskop liczymy ponownie, zamiast go zapamiętywać: to czysta funkcja
danych wejściowych — tanio ją powtórzyć, a odpada trzymanie w przeglądarce
dużego wyniku, który mógłby się rozjechać z aktualnym formularzem.
"""
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "zodiac": zodiac}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, zodiac=zodiac,
)
from app import chartwheel
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "compile.html", ctx)
@app.post("/compile/pdf")
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)
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
zawierał dokładnie to, co widać na stronie.
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
"""
from fastapi.responses import Response
from app.clients.render_client import RenderClient
data = payload.get("data") or {}
try:
iso_utc, label = _build_utc(
str(data.get("date") or ""), str(data.get("time") or ""),
float(data.get("tz_offset") or 0.0),
)
except (ValueError, TypeError) as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
wheel_svg = ""
try:
chart = logic.positions(
when_utc_iso=iso_utc,
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
house_system=str(data.get("house_system") or "whole_sign"),
zodiac=str(data.get("zodiac") or "tropical"),
)
from app import chartwheel
wheel_svg = chartwheel.render(chart, theme="print")
except httpx.HTTPError as e:
# Brak rysunku nie może zablokować raportu — tekst jest ważniejszy.
log_note = _logic_error(e)
chart, wheel_svg = None, ""
data = {**data, "wheel_error": log_note}
report = {
"person": payload.get("person") or "",
"data": {**data, "moment_utc": label},
"wheel_svg": wheel_svg,
"natal": payload.get("natal") or {},
"predictions": payload.get("predictions") or [],
}
try:
pdf = RenderClient().pdf(report)
except httpx.HTTPError as e:
return JSONResponse(
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
status_code=502,
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ---------------- # ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
@app.get("/significators", response_class=HTMLResponse) @app.get("/significators", response_class=HTMLResponse)
def significators_form(request: Request): def significators_form(request: Request):
@@ -244,7 +135,6 @@ def interpret_form(request: Request):
@app.post("/interpret", response_class=HTMLResponse) @app.post("/interpret", response_class=HTMLResponse)
def interpret_run( def interpret_run(
request: Request, request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...), date: str = Form(...),
time: str = Form(...), time: str = Form(...),
tz_offset: float = Form(0.0), tz_offset: float = Form(0.0),
@@ -256,7 +146,7 @@ def interpret_run(
llm_provider: str = Form("local"), llm_provider: str = Form("local"),
llm_model: str = Form(""), llm_model: str = Form(""),
): ):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget, "lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
"llm_provider": llm_provider, "llm_model": llm_model} "llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None, ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
@@ -296,7 +186,6 @@ def timeline_form(request: Request):
@app.post("/timeline", response_class=HTMLResponse) @app.post("/timeline", response_class=HTMLResponse)
def timeline_run( def timeline_run(
request: Request, request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...), date: str = Form(...),
time: str = Form(...), time: str = Form(...),
tz_offset: float = Form(0.0), tz_offset: float = Form(0.0),
@@ -309,7 +198,7 @@ def timeline_run(
llm_provider: str = Form("local"), llm_provider: str = Form("local"),
llm_model: str = Form(""), llm_model: str = Form(""),
): ):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget, "from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
"llm_provider": llm_provider, "llm_model": llm_model} "llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None, ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
-159
View File
@@ -1,159 +0,0 @@
// Zakładka „Skompiluj" — składanie raportu (PRE-23).
//
// 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).
//
// 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
// localStorage — dzięki temu format danych ma JEDNEGO właściciela.
//
// Pokazujemy też, CZEGO BRAKUJE. Bez tego użytkownik złożyłby niekompletny raport
// i dowiedział się o tym dopiero po otwarciu PDF-a.
(function () {
'use strict';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function readNatal() {
return (window.astrololoNatal && window.astrololoNatal.read()) || null;
}
function readPredictions() {
return (window.astrololoPredictions && window.astrololoPredictions.read()) || [];
}
function item(ok, label, hint) {
return '<li class="' + (ok ? 'rdy-ok' : 'rdy-missing') + '">' +
(ok ? '✓ ' : '✗ ') + esc(label) +
(ok ? '' : ' <span class="muted small">— ' + esc(hint) + '</span>') + '</li>';
}
function renderReadiness(hasChart) {
var host = document.getElementById('readiness');
if (!host) return;
var natal = readNatal();
var preds = readPredictions();
var parts = [
item(hasChart, 'Policzony horoskop', 'wypełnij dane i kliknij „Złóż podsumowanie"'),
item(!!natal, 'Interpretacja natalna',
'wygeneruj ją na zakładce Interpretacje — zapamięta się sama'),
item(preds.length > 0,
'Predykcje okresowe' + (preds.length ? ' (' + preds.length + ')' : ''),
'policz horoskop na wybrany okres w Kalendarzu')
];
host.innerHTML = '<div class="meta">Materiał do raportu</div><ul class="readiness">' +
parts.join('') + '</ul>';
}
function renderNatal() {
var host = document.getElementById('reportNatal');
if (!host) return;
var natal = readNatal();
if (!natal) {
host.innerHTML = '<div class="meta">Interpretacja natalna</div>' +
'<p class="muted small">Brak — wygeneruj ją na zakładce Interpretacje.</p>';
return;
}
host.innerHTML = '<div class="meta">Interpretacja natalna ' +
'<span class="muted small">· zapamiętana ' + esc(String(natal.saved_at).slice(0, 10)) +
'</span></div><div class="report-text">' + esc(natal.text) + '</div>';
}
function renderPredictions() {
var host = document.getElementById('reportPredictions');
if (!host) return;
var preds = readPredictions();
if (!preds.length) {
host.innerHTML = '<div class="meta">Predykcje okresowe</div>' +
'<p class="muted small">Brak — policz horoskop na wybrany okres w Kalendarzu.</p>';
return;
}
var blocks = preds.map(function (p) {
return '<div class="meta">' + esc(p.from_date) + ' ' + esc(p.to_date) + '</div>' +
'<div class="report-text">' + esc(p.text) + '</div>';
}).join('');
host.innerHTML = '<div class="meta">Predykcje okresowe (' + preds.length + ')</div>' + blocks;
}
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
// ── PDF (PRE-24) ────────────────────────────────────────────────────
// Części od AI 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) {
var el = document.querySelector('[name=' + name + ']');
return el ? el.value : '';
}
return {
date: v('date'), time: v('time'), tz_offset: v('tz_offset'),
lat: v('lat'), lon: v('lon'),
house_system: v('house_system'), zodiac: v('zodiac'),
place: (document.querySelector('#geoSearch') || {}).value || ''
};
}
function download(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'raport.pdf';
document.body.appendChild(a);
a.click();
a.remove();
// Zwalniamy obiekt dopiero po kliknięciu — wcześniej przeglądarka nie zdąży
// rozpocząć pobierania.
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
}
function buildPdf(btn, note) {
var person = (document.querySelector('[name=person]') || {}).value || '';
var payload = {
person: person,
data: formData(),
natal: readNatal() || {},
predictions: readPredictions()
};
btn.disabled = true;
note.textContent = 'Składam PDF — LaTeX potrzebuje chwili przy obszernym tekście…';
fetch('/compile/pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) {
if (!r.ok) {
return r.json().catch(function () { return {}; }).then(function (d) {
throw new Error(d.detail || ('HTTP ' + r.status));
});
}
return r.blob();
}).then(function (blob) {
download(blob);
note.textContent = 'Gotowe — PDF pobrany.';
}).catch(function (e) {
note.textContent = 'Nie udało się złożyć PDF-a: ' + e.message;
}).finally(function () {
btn.disabled = false;
});
}
ready(function () {
if (!document.getElementById('readiness')) return; // nie ta zakładka
// Horoskop jest w dokumencie tylko po wysłaniu formularza.
renderReadiness(!!document.getElementById('reportPerson'));
renderNatal();
renderPredictions();
var btn = document.getElementById('pdfBtn');
var note = document.getElementById('pdfNote');
if (btn && note) btn.addEventListener('click', function () { buildPdf(btn, note); });
});
})();
@@ -1,98 +0,0 @@
// Wspólne dane formularza między zakładkami (PRE-21).
//
// Problem: każdą zakładkę (Horoskop / Interpretacje / Kalendarz) wypełniało się
// od nowa — te same imię, data, godzina, strefa i miejsce. Teraz raz wpisane
// dane wędrują za użytkownikiem.
//
// Dlaczego localStorage, a nie sesja na serwerze: serwer zostaje BEZSTANOWY
// (żadnego magazynu sesji, żadnych danych urodzeniowych trzymanych po stronie
// usługi), a przy okazji dane synchronizują się też między osobnymi kartami
// przeglądarki — zdarzenie `storage` daje to za darmo.
//
// UWAGA na kolejność: ten skrypt ładujemy w <head> z `defer`, więc wykonuje się
// PO sparsowaniu dokumentu, ale PRZED geo.js (skrypty defer idą w kolejności
// dokumentu). Dzięki temu mapa startuje już z odtworzonymi współrzędnymi i nie
// trzeba jej potem przestawiać.
(function () {
'use strict';
var KEY = 'astrololo.form.v1';
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id.
var FIELDS = [
{ key: 'person', sel: 'input[name=person]' },
{ key: 'date', sel: 'input[name=date]' },
{ key: 'time', sel: 'input[name=time]' },
{ key: 'tz_offset', sel: 'input[name=tz_offset]' },
{ key: 'lat', sel: 'input[name=lat]' },
{ key: 'lon', sel: 'input[name=lon]' },
{ key: 'house_system', sel: 'select[name=house_system]' },
{ key: 'zodiac', sel: 'select[name=zodiac]' },
{ key: 'place', sel: '#geoSearch' }
];
function load() {
try {
return JSON.parse(localStorage.getItem(KEY) || '{}') || {};
} catch (e) {
return {}; // uszkodzony wpis nie może zablokować strony
}
}
function save(state) {
try {
localStorage.setItem(KEY, JSON.stringify(state));
} catch (e) {
/* tryb prywatny albo brak miejsca — trudno, formularz ma działać dalej */
}
}
function apply(state) {
FIELDS.forEach(function (f) {
var el = document.querySelector(f.sel);
if (!el) return;
var v = state[f.key];
if (v === undefined || v === null || v === '') return;
if (el.value !== v) el.value = v;
});
}
function collect() {
var state = load();
FIELDS.forEach(function (f) {
var el = document.querySelector(f.sel);
if (!el) return; // pola nieobecnego na tej zakładce nie kasujemy
if (el.value === '' || el.value === null) return;
state[f.key] = el.value;
});
return state;
}
// 1) odtwórz to, co użytkownik wpisał gdzie indziej
apply(load());
// 2) zapisuj każdą zmianę (input dla wpisywania, change dla list i kalendarzy)
FIELDS.forEach(function (f) {
var el = document.querySelector(f.sel);
if (!el) return;
['input', 'change'].forEach(function (ev) {
el.addEventListener(ev, function () { save(collect()); });
});
});
// 3) zapisz stan po wysłaniu formularza — po przeładowaniu strony pola mają
// pokazywać to, co faktycznie policzyliśmy
document.addEventListener('submit', function () { save(collect()); }, true);
// 4) druga otwarta karta zmieniła dane -> dociągnij je tutaj na żywo
window.addEventListener('storage', function (e) {
if (e.key === KEY) apply(load());
});
// 5) geo.js ustawia lat/lon z mapy poza polami formularza — po takiej zmianie
// też chcemy zapamiętać (zdarzenie leci z „Tu i teraz" oraz z mapy)
document.addEventListener('astrololo:coords', function () {
setTimeout(function () { save(collect()); }, 0);
});
})();
+4 -23
View File
@@ -38,32 +38,18 @@
// Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka. // Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka.
setTimeout(function () { map.invalidateSize(); }, 60); setTimeout(function () { map.invalidateSize(); }, 60);
// Ustawienie `.value` z kodu NIE wywołuje zdarzeń, więc synchronizacja
// między zakładkami (formsync.js) przegapiłaby zmiany z mapy. Zgłaszamy je
// jawnie przez `change`.
function setVal(el, value) {
if (!el) return;
el.value = value;
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function setCoords(lat, lon, fromMap) { function setCoords(lat, lon, fromMap) {
setVal(latEl, lat.toFixed(4)); latEl.value = lat.toFixed(4);
setVal(lonEl, lon.toFixed(4)); lonEl.value = lon.toFixed(4);
marker.setLatLng([lat, lon]); marker.setLatLng([lat, lon]);
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12)); if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
if (fromMap) reverseName(lat, lon); if (fromMap) reverseName(lat, lon);
} }
function reverseName(lat, lon) { function reverseName(lat, lon) {
// Czyścimy pole OD RAZU. Dopóki nie znamy nazwy nowego punktu, stara jest
// po prostu nieprawdziwa — pokazywałaby inne miejsce niż współrzędne obok.
// Dotyczy też sytuacji, gdy /reverse nie odpowie (brak sieci): lepiej puste
// pole i poprawne współrzędne niż nazwa, która kłamie.
if (searchEl) setVal(searchEl, '');
fetch('/reverse?lat=' + lat + '&lon=' + lon) fetch('/reverse?lat=' + lat + '&lon=' + lon)
.then(function (r) { return r.ok ? r.json() : null; }) .then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.name && searchEl) setVal(searchEl, d.name); }) .then(function (d) { if (d && d.name) searchEl.value = d.name; })
.catch(function () {}); .catch(function () {});
} }
@@ -82,7 +68,7 @@
li.textContent = it.name; li.textContent = it.name;
li.addEventListener('click', function () { li.addEventListener('click', function () {
setCoords(it.lat, it.lon, false); setCoords(it.lat, it.lon, false);
setVal(searchEl, it.name); searchEl.value = it.name;
resultsEl.hidden = true; resultsEl.hidden = true;
}); });
resultsEl.appendChild(li); resultsEl.appendChild(li);
@@ -122,14 +108,9 @@
}); });
// ---- synchronizacja z „Tu i teraz" ---- // ---- synchronizacja z „Tu i teraz" ----
// Poza przesunięciem pineski MUSIMY odświeżyć pole tekstowe: po „Tu i teraz"
// zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno
// miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu,
// na który faktycznie ustawiła się mapa.
document.addEventListener('astrololo:coords', function () { document.addEventListener('astrololo:coords', function () {
marker.setLatLng([curLat(), curLon()]); marker.setLatLng([curLat(), curLon()]);
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12)); map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
reverseName(curLat(), curLon());
}); });
}); });
})(); })();
-97
View File
@@ -1,97 +0,0 @@
// Zapamiętana interpretacja natalna (PRE-23).
//
// Odpowiednik predictions.js, tylko że natalna jest JEDNA — dotyczy momentu
// urodzenia, nie wybranego okresu. Ponowne wygenerowanie podmienia poprzednią.
//
// Po co magazyn: zakładka „Skompiluj" składa raport z trzech kawałków liczonych
// w różnych miejscach (horoskop, interpretacja natalna, predykcje okresowe).
// Bez zapamiętania trzeba by je generować od nowa na jednej stronie.
//
// localStorage, tak jak reszta stanu (PRE-21/22) — serwer zostaje bezstanowy.
(function () {
'use strict';
var KEY = 'astrololo.natal.v1';
function read() {
try {
var v = JSON.parse(localStorage.getItem(KEY) || 'null');
return (v && typeof v === 'object' && v.text) ? v : null;
} catch (e) {
return null; // uszkodzony wpis nie może zablokować strony
}
}
function clear() {
try { localStorage.removeItem(KEY); } catch (e) { /* trudno */ }
}
function val(sel) {
var el = document.querySelector(sel);
return el ? el.value.trim() : '';
}
function save() {
var text = val('#horoscopeText');
if (!text) return false; // nie ma czego zapamiętać
var entry = {
text: text,
person: val('input[name=person]'),
date: val('input[name=date]'),
time: val('input[name=time]'),
tz_offset: val('input[name=tz_offset]'),
lat: val('input[name=lat]'),
lon: val('input[name=lon]'),
saved_at: new Date().toISOString()
};
try {
localStorage.setItem(KEY, JSON.stringify(entry));
return true;
} catch (e) {
return false; // przepełniony magazyn — zgłaszamy wyżej
}
}
function note(msg) {
var el = document.getElementById('natalNote');
if (el) el.textContent = msg;
}
function saveCurrent() {
var text = val('#horoscopeText');
if (!text) return;
if (save()) {
note('Interpretacja natalna zapamiętana — trafi do raportu (zakładka Skompiluj).');
} else {
// Interpretacje bywają długie; cicha strata byłaby najgorsza.
note('Nie udało się zapamiętać — magazyn przeglądarki pełny. '
+ 'Usuń starsze predykcje i spróbuj ponownie.');
}
}
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
if (!document.getElementById('natalNote')) return; // nie ta zakładka
// 1) interpretacja policzona przez okno postępu (strumień)
document.addEventListener('astrololo:horoscope', function (e) {
if (!e.detail || e.detail.profile === 'natal') saveCurrent();
});
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
// POST. Zapis nadpisuje jeden slot, więc odświeżenie niczego nie mnoży.
if (document.querySelector('#horoscopeText')) saveCurrent();
var status = read();
if (status) {
note('W raporcie jest zapamiętana interpretacja natalna z '
+ String(status.saved_at).slice(0, 10) + '.');
}
});
window.astrololoNatal = { read: read, clear: clear, key: KEY };
})();
@@ -1,150 +0,0 @@
// Zapamiętane predykcje okresowe (PRE-22).
//
// Kalendarz pozwala policzyć horoskop na wybrany okres. Partnerzy chcą kilku
// okresów naraz — i żeby wszystkie trafiły potem do raportu (zakładka
// „Skompiluj", PRE-23/24). Tu jest magazyn i lista.
//
// Trzymamy w localStorage, tak jak wspólne dane formularza (PRE-21): serwer
// zostaje bezstanowy, żadne dane urodzeniowe ani treści z baz nie lądują po
// stronie usługi. Zakładka „Skompiluj" odczyta to samo miejsce.
(function () {
'use strict';
var KEY = 'astrololo.predictions.v1';
function read() {
try {
var v = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(v) ? v : [];
} catch (e) {
return []; // uszkodzony wpis nie może zablokować strony
}
}
function write(list) {
try {
localStorage.setItem(KEY, JSON.stringify(list));
return true;
} catch (e) {
return false; // najczęściej przepełniony magazyn
}
}
function byPeriod(a, b) {
return String(a.from_date).localeCompare(String(b.from_date));
}
// Klucz tożsamości to OKRES. Ponowne policzenie tego samego zakresu ma
// podmienić wpis, a nie dokładać duplikat — inaczej lista puchłaby przy
// każdej próbie z innym modelem.
function upsert(entry) {
var list = read().filter(function (p) {
return !(p.from_date === entry.from_date && p.to_date === entry.to_date);
});
list.push(entry);
list.sort(byPeriod);
return write(list) ? list : null;
}
function remove(id) {
var list = read().filter(function (p) { return p.id !== id; });
write(list);
return list;
}
function val(sel) {
var el = document.querySelector(sel);
return el ? el.value.trim() : '';
}
/** Wyciąga świeżo policzony horoskop okresowy ze strony. */
function capture() {
var text = val('#horoscopeText');
var from = val('input[name=from_date]');
var to = val('input[name=to_date]');
if (!text || !from || !to) return null;
return {
id: from + '..' + to,
from_date: from,
to_date: to,
person: val('input[name=person]'),
text: text,
saved_at: new Date().toISOString()
};
}
function fmtDate(iso) {
return String(iso || '').slice(0, 10);
}
function render() {
var host = document.getElementById('savedPredictions');
if (!host) return;
var list = read();
if (!list.length) {
host.innerHTML = '<p class="muted small">Brak zapamiętanych predykcji. ' +
'Policz horoskop na wybrany okres — zapamięta się sam i trafi do raportu.</p>';
return;
}
var rows = list.map(function (p) {
var chars = (p.text || '').length;
return '<tr>' +
'<td class="nowrap">' + fmtDate(p.from_date) + ' ' + fmtDate(p.to_date) + '</td>' +
'<td class="muted small">' + fmtDate(p.saved_at) + '</td>' +
'<td class="muted small">' + chars.toLocaleString('pl-PL') + ' znaków</td>' +
'<td><button type="button" class="ghost" data-drop="' +
String(p.id).replace(/"/g, '&quot;') + '">Usuń</button></td>' +
'</tr>';
}).join('');
host.innerHTML =
'<div class="meta">Zapamiętane predykcje (' + list.length + ') — wejdą do raportu</div>' +
'<table class="angles"><thead><tr><th>Okres</th><th>Zapisano</th>' +
'<th>Objętość</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
Array.prototype.forEach.call(host.querySelectorAll('[data-drop]'), function (btn) {
btn.addEventListener('click', function () {
remove(btn.getAttribute('data-drop'));
render();
});
});
}
function saveCurrent() {
var entry = capture();
if (!entry) return;
var note = document.getElementById('predictionsNote');
if (upsert(entry)) {
if (note) note.textContent = 'Zapamiętano predykcję ' +
entry.from_date + ' ' + entry.to_date + '.';
} else if (note) {
// Horoskopy bywają długie; gdy magazyn się przepełni, trzeba powiedzieć
// wprost, zamiast po cichu zgubić wynik.
note.textContent = 'Nie udało się zapamiętać — magazyn przeglądarki pełny. ' +
'Usuń starsze predykcje i spróbuj ponownie.';
}
render();
}
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
if (!document.getElementById('savedPredictions')) return; // nie ta zakładka
render();
// 1) horoskop policzony przez okno postępu (strumień)
document.addEventListener('astrololo:horoscope', function (e) {
if (!e.detail || e.detail.profile === 'period') saveCurrent();
});
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
// POST. Zapis jest idempotentny (klucz = okres), więc odświeżenie strony
// niczego nie duplikuje.
if (document.querySelector('#horoscopeText')) saveCurrent();
});
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
window.astrololoPredictions = { read: read, remove: remove, key: KEY };
})();
@@ -113,11 +113,6 @@ document.addEventListener('DOMContentLoaded', function () {
overlay.hidden = true; overlay.hidden = true;
const host = document.getElementById('promptResult'); const host = document.getElementById('promptResult');
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' }); if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Gotowy horoskop ogłaszamy zdarzeniem, zamiast zapisywać go tutaj:
// to okno postępu, a nie magazyn. Nasłuchuje predictions.js (PRE-22).
document.dispatchEvent(new CustomEvent('astrololo:horoscope', {
detail: { profile: profile }
}));
return; return;
} }
addLine(ev.message || ev.type, ev.type === 'error' ? 'err' addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
@@ -32,42 +32,6 @@ h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); } button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; } td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text — nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
.wheel-fig figcaption { margin-top: .4rem; }
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
svg.wheel text.glyph { font-size: inherit; }
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
zostać na wierzchu. */
.wheel-fig svg.wheel { cursor: zoom-in; }
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.wheel-fig.zoomed {
position: fixed; inset: 0; z-index: 1100; margin: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: .5rem; padding: 1rem; background: var(--bg);
}
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
.wheel-fig.zoomed svg.wheel {
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
}
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; } form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
.row { display: flex; gap: .5rem; } .row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; } .row input[type=text] { flex: 1; }
@@ -152,15 +116,3 @@ textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.progress-log li.log-ok { color: #7fd18b; } .progress-log li.log-ok { color: #7fd18b; }
.progress-log li.log-warn { color: #e0c060; } .progress-log li.log-warn { color: #e0c060; }
.progress-log li.log-err { color: #ef6b6b; } .progress-log li.log-err { color: #ef6b6b; }
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
.readiness li { padding: .2rem 0; }
.readiness .rdy-ok { color: #7fd18b; }
.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,
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; }
@@ -1,60 +0,0 @@
// Powiększanie kosmogramu na pełne okno (PRE-25).
//
// Koło rysujemy w kolumnie tekstu, więc na małym ekranie glify i stopnie robią
// się nieczytelne. Kliknięcie rozciąga wykres na całe okno, ponowne wraca do
// strony. SVG skaluje się bez utraty jakości, więc nie potrzeba drugiej wersji
// rysunku ani żadnej biblioteki.
//
// Podpięte globalnie i osłonięte sprawdzeniem, czy koło w ogóle jest na stronie
// — dzięki temu zadziała też na przyszłej zakładce „Skompiluj", bez zmian tutaj.
(function () {
'use strict';
var ZOOMED = 'zoomed';
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
var figures = document.querySelectorAll('.wheel-fig');
if (!figures.length) return;
Array.prototype.forEach.call(figures, function (fig) {
var svg = fig.querySelector('svg.wheel');
if (!svg) return;
// Klawiatura ma działać tak samo jak mysz — to nie jest ozdobnik, tylko
// jedyny sposób na obejrzenie szczegółów wykresu.
svg.setAttribute('tabindex', '0');
svg.setAttribute('role', 'button');
svg.setAttribute('aria-label', 'Kosmogram — kliknij, aby powiększyć na całe okno');
function toggle() {
var on = fig.classList.toggle(ZOOMED);
// Blokujemy przewijanie strony pod spodem: inaczej scroll „przecieka"
// na treść, której i tak nie widać.
document.body.classList.toggle('wheel-zoom-open', on);
svg.setAttribute('aria-label', on
? 'Kosmogram powiększony — kliknij lub naciśnij Esc, aby wrócić'
: 'Kosmogram — kliknij, aby powiększyć na całe okno');
if (on) svg.focus();
}
svg.addEventListener('click', toggle);
svg.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
// Klik w tło (poza samym rysunkiem) też zamyka — naturalne dla nakładki.
fig.addEventListener('click', function (e) {
if (e.target === fig && fig.classList.contains(ZOOMED)) toggle();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && fig.classList.contains(ZOOMED)) toggle();
});
});
});
})();
@@ -5,14 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · {% block title %}{% endblock %}</title> <title>astrololo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
{# Wspólne dane formularza między zakładkami (PRE-21). W <head> z `defer`
CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży
odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu
we właściwym miejscu, bez przestawiania. #}
<script defer src="/static/formsync.js"></script>
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
<script defer src="/static/wheelzoom.js"></script>
</head> </head>
<body> <body>
<main> <main>
@@ -23,7 +15,6 @@
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a> <a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a> <a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a> <a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
</nav> </nav>
</header> </header>
{% block content %}{% endblock %} {% block content %}{% endblock %}
+3 -19
View File
@@ -6,12 +6,6 @@
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p> <p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
<form method="post" action="/"> <form method="post" action="/">
<div class="grid">
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
value="{{ form.person or '' }}">
</label>
</div>
<div class="grid"> <div class="grid">
<label>Data <label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required> <input type="date" name="date" value="{{ form.date or '' }}" required>
@@ -19,9 +13,8 @@
<label>Godzina (lokalna) <label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required> <input type="time" name="time" value="{{ form.time or '' }}" required>
</label> </label>
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h) <label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" min="-12" max="14" <input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label> </label>
</div> </div>
<div class="grid"> <div class="grid">
@@ -78,13 +71,6 @@
{% if moment %}· moment: {{ moment }}{% endif %} {% if moment %}· moment: {{ moment }}{% endif %}
</div> </div>
{% if wheel_svg %}
<figure class="wheel-fig">
{{ wheel_svg | safe }}
<figcaption class="muted small">Kosmogram — znaki, domy, osie i obiekty. Kreska na pasie wskazuje dokładną pozycję; glif bywa odsunięty, gdy obiekty stoją ciasno. Linie aspektów w kolejnym kroku.</figcaption>
</figure>
{% endif %}
{% if result.angles %} {% if result.angles %}
<table class="angles"> <table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead> <thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
@@ -100,7 +86,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th> <th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th> <th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
</tr> </tr>
@@ -108,7 +93,6 @@
<tbody> <tbody>
{% for p in result.positions %} {% for p in result.positions %}
<tr> <tr>
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
<td>{{ p.name }}</td> <td>{{ p.name }}</td>
<td>{{ p.sign }}</td> <td>{{ p.sign }}</td>
<td class="mono">{{ p.in_sign }}</td> <td class="mono">{{ p.in_sign }}</td>
@@ -156,7 +140,7 @@
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead> <thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody> <tbody>
{% for a in result.aspects %} {% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr> <tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -1,127 +0,0 @@
{% extends "base.html" %}
{% block title %}Skompiluj{% endblock %}
{% block nav_compile %}active{% endblock %}
{% block content %}
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #}
<div id="readiness"></div>
<form method="post" action="/compile">
<div class="grid">
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
value="{{ form.person or '' }}">
</label>
</div>
<div class="grid">
<label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required>
</label>
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h)
<input type="number" name="tz_offset" step="0.25" min="-12" max="14"
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
</div>
<div class="grid">
<label>Szerokość (lat, + N)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
</label>
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
</select>
</label>
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
<option value="tropical" {{ 'selected' if zd == 'tropical' else '' }}>Tropikalny</option>
<option value="sidereal_lahiri" {{ 'selected' if zd == 'sidereal_lahiri' else '' }}>Syderyczny (Lahiri)</option>
<option value="sidereal_fagan_bradley" {{ 'selected' if zd == 'sidereal_fagan_bradley' else '' }}>Syderyczny (Fagan-Bradley)</option>
<option value="sidereal_krishnamurti" {{ 'selected' if zd == 'sidereal_krishnamurti' else '' }}>Syderyczny (Krishnamurti)</option>
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
</div>
<div class="actions">
<button type="submit">Złóż podsumowanie</button>
<button type="button" id="pdfBtn" class="ghost"
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
<span id="pdfNote" class="muted small"></span>
</div>
</form>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% if result %}
{# ── 1. Nagłówek: kto ──────────────────────────────────────────────── #}
<div class="report-head">
<h2 id="reportPerson">{{ form.person or 'Bez podanego imienia' }}</h2>
<p class="muted small">
{{ form.date }} {{ form.time }} · offset {{ form.tz_offset }} h względem GMT ·
{{ '%.4f'|format(form.lat|float) }}, {{ '%.4f'|format(form.lon|float) }}
{% if moment %}· {{ moment }}{% endif %}
</p>
</div>
{# ── 2. Kosmogram ─────────────────────────────────────────────────── #}
{% if wheel_svg %}
<figure class="wheel-fig">
{{ wheel_svg | safe }}
<figcaption class="muted small">Kosmogram — kliknij, aby powiększyć.</figcaption>
</figure>
{% endif %}
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
<div class="meta">Horoskop · silnik {{ result.engine }}
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
<table>
<thead><tr><th>Sym.</th><th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th></tr></thead>
<tbody>
{% for p in result.positions %}
<tr>
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
<td>{{ p.name }}</td><td>{{ p.sign }}</td><td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
<div id="reportNatal"></div>
<div id="reportPredictions"></div>
{% endif %}
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #}
<script src="/static/natal.js"></script>
<script src="/static/predictions.js"></script>
<script src="/static/compile.js"></script>
{% endblock %}
@@ -6,12 +6,6 @@
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p> <p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
<form method="post" action="/interpret"> <form method="post" action="/interpret">
<div class="grid">
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
value="{{ form.person or '' }}">
</label>
</div>
<div class="grid"> <div class="grid">
<label>Data <label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required> <input type="date" name="date" value="{{ form.date or '' }}" required>
@@ -19,9 +13,8 @@
<label>Godzina (lokalna) <label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required> <input type="time" name="time" value="{{ form.time or '' }}" required>
</label> </label>
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h) <label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" min="-12" max="14" <input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label> </label>
<label>Szerokość (lat) <label>Szerokość (lat)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}"> <input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
@@ -36,7 +29,6 @@
</div> </div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %} {% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
{% include "_prompt_block.html" %} {% include "_prompt_block.html" %}
<p class="muted small" id="natalNote"></p>
<div class="actions"> <div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button> <button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit" name="action" value="report">Szukaj interpretacji</button> <button type="submit" name="action" value="report">Szukaj interpretacji</button>
@@ -100,6 +92,4 @@
<script src="/static/copy.js"></script> <script src="/static/copy.js"></script>
<script src="/static/models.js"></script> <script src="/static/models.js"></script>
<script src="/static/progress.js"></script> <script src="/static/progress.js"></script>
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
<script src="/static/natal.js"></script>
{% endblock %} {% endblock %}
@@ -6,12 +6,6 @@
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p> <p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
<form method="post" action="/timeline"> <form method="post" action="/timeline">
<div class="grid">
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
value="{{ form.person or '' }}">
</label>
</div>
<div class="grid"> <div class="grid">
<label>Data urodzenia <label>Data urodzenia
<input type="date" name="date" value="{{ form.date or '' }}" required> <input type="date" name="date" value="{{ form.date or '' }}" required>
@@ -19,9 +13,8 @@
<label>Godzina (lokalna) <label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required> <input type="time" name="time" value="{{ form.time or '' }}" required>
</label> </label>
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h) <label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" min="-12" max="14" <input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label> </label>
<label>Szerokość (lat) <label>Szerokość (lat)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}"> <input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
@@ -85,16 +78,8 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{# Zapamiętane predykcje okresowe (PRE-22) — materiał dla zakładki „Skompiluj" #}
<section id="predictionsBox">
<div id="savedPredictions"></div>
<p class="muted small" id="predictionsNote"></p>
</section>
<script src="/static/now.js"></script> <script src="/static/now.js"></script>
<script src="/static/copy.js"></script> <script src="/static/copy.js"></script>
<script src="/static/models.js"></script> <script src="/static/models.js"></script>
<script src="/static/progress.js"></script> <script src="/static/progress.js"></script>
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
<script src="/static/predictions.js"></script>
{% endblock %} {% endblock %}
@@ -1,195 +0,0 @@
"""Kosmogram — koło horoskopowe jako SVG (PRE-12).
Sprawdzamy, że SVG jest POPRAWNY (parsuje się jako XML — inaczej wstawiony do
strony rozjedzie render) i że niesie to, co ma nieść: pierścień 12 znaków, cztery
osie, numery domów i obiekty. Geometria (Asc po lewej) oraz rozsuwanie ciasnych
skupisk sprawdzone liczbowo, nie na oko.
"""
import pytest
import xml.dom.minidom as minidom
from app import chartwheel
def _sample_chart(asc=128.9, mc=5.0):
"""Minimalny wynik z warstwy logiki: osie + cuspy (whole sign) + pierścień znaków."""
signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
glyphs = ["", "", "", "", "", "", "", "", "", "", "", ""]
asc_sign = int(asc // 30)
cusps = [{"house": i + 1, "sign": signs[(asc_sign + i) % 12],
"decimal": (asc_sign * 30 + i * 30) % 360, "sign_glyph": glyphs[(asc_sign + i) % 12]}
for i in range(12)]
return {
"angles": {
"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]},
"MC": {"name": "MC", "decimal": mc},
"Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360},
"IC": {"name": "IC", "decimal": (mc + 180) % 360},
},
"cusps": cusps,
"sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)],
}
def _rendered():
return chartwheel.render(_sample_chart())
def test_output_is_well_formed_xml():
"""Wstawiamy surowy SVG do strony — musi być poprawnym XML, bo inaczej
zepsuje cały dokument."""
svg = _rendered().replace("var(--line)", "#000").replace(
"var(--accent)", "#000").replace("var(--muted)", "#000")
doc = minidom.parseString(svg) # rzuci wyjątkiem, gdy błędny
assert doc.documentElement.tagName == "svg"
def test_has_twelve_sign_glyphs():
assert _rendered().count('class="glyph"') == 12
def test_has_all_four_axis_labels():
svg = _rendered()
for label in (">AC<", ">DC<", ">MC<", ">IC<"):
assert label in svg
def test_has_house_numbers_one_to_twelve():
svg = _rendered()
for h in range(1, 13):
assert f">{h}<" in svg
def test_ascendant_is_on_the_left():
"""Konwencja: Asc po lewej. Sprawdzamy oś ACDC liczbowo — punkt Asc musi mieć
x wyraźnie mniejszy od środka (220), a Dsc większy."""
chart = _sample_chart(asc=128.9)
asc = chart["angles"]["Asc"]["decimal"]
ax, ay = chartwheel._pt(asc, asc, chartwheel.R_ZOD)
dx, dy = chartwheel._pt(asc + 180, asc, chartwheel.R_ZOD)
assert ax < chartwheel._CX - 100, "Asc powinien być po lewej"
assert dx > chartwheel._CX + 100, "Dsc powinien być po prawej"
assert abs(ay - chartwheel._CY) < 1e-6, "oś AscDsc jest pozioma"
def test_longitude_increases_counterclockwise():
"""Asc+90° (II dom) ląduje na DOLE koła (y > środek, bo w SVG y rośnie w dół)."""
asc = 100.0
_, y = chartwheel._pt(asc + 90, asc, chartwheel.R_ZOD)
assert y > chartwheel._CY + 100
def test_empty_when_no_angles():
"""Silnik bez osi/domów (brak sidereal) → brak koła, nie wyjątek."""
assert chartwheel.render({"positions": []}) == ""
assert chartwheel.render({"angles": {}, "cusps": [], "sign_glyphs": []}) == ""
# ───────────────────────── rozsuwanie nakładających się obiektów ─────────
# Prawdziwe długości z horoskopu referencyjnego (1984-04-30, Kraków), w kolejności
# DEFAULT_OBJECTS. Merkury i Wenus dzieli 0,41°, Wenus i Księżyc 3,68° — bez
# rozsuwania glify rysują się jeden na drugim.
_REAL = [40.210, 31.449, 27.355, 27.767, 234.520, 282.962, 223.306,
252.811, 271.220, 210.477, 68.158, 248.158, 345.667]
def _min_gap(angles):
s = sorted(a % 360.0 for a in angles)
return min((s[(i + 1) % len(s)] - s[i]) % 360.0 for i in range(len(s)))
def test_spread_separates_real_chart_cluster():
"""REGRESJA: przy liczeniu odstępu modulo 360 przesunięcie, które przerzucało
obiekt ZA sąsiada, dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że
jest luzem, i kończył z Wenus dokładnie na Księżycu (0,3 px od siebie)."""
out = chartwheel.spread(_REAL)
assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
def test_spread_preserves_order():
out = chartwheel.spread(_REAL)
by_new = [i for i, _ in sorted(enumerate(out), key=lambda p: p[1])]
by_old = [i for i, _ in sorted(enumerate(_REAL), key=lambda p: p[1] % 360.0)]
assert by_new == by_old
def test_spread_leaves_roomy_objects_untouched():
"""Obiekty, które nikomu nie wchodzą w drogę, mają zostać na swoim miejscu —
wykres ma kłamać jak najmniej."""
out = chartwheel.spread(_REAL)
node = _REAL.index(68.158) # Węzeł Płn. — 28° od najbliższego
assert out[node] == pytest.approx(_REAL[node], abs=1e-6)
def test_spread_handles_wrap_around_zero():
"""Skupisko na styku 0/360° też musi się rozsunąć."""
out = chartwheel.spread([358.0, 359.0, 1.0, 2.0])
assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
def test_spread_falls_back_to_even_layout_when_impossible():
"""Gdy obiektów jest tyle, że min. odstęp się nie zmieści — rozkładamy równo,
zamiast kręcić się w pętli."""
many = [10.0] * 60 # 60 × 8° = 480° > 360°
out = chartwheel.spread(many)
assert _min_gap(out) == pytest.approx(360.0 / 60, abs=1e-6)
def test_spread_single_object_is_identity():
assert chartwheel.spread([42.0]) == [42.0]
# ─────────────────────────────── obiekty na kole ─────────────────────────
def _chart_with_objects():
chart = _sample_chart()
chart["positions"] = [
{"name": "Sun", "decimal": 40.21, "glyph": "", "direction": "D"},
{"name": "Moon", "decimal": 31.45, "glyph": "", "direction": "D"},
{"name": "Venus", "decimal": 27.77, "glyph": "", "direction": "D"},
{"name": "Mercury", "decimal": 27.36, "glyph": "", "direction": "Rx"},
]
return chart
def test_all_objects_are_drawn():
svg = chartwheel.render(_chart_with_objects())
for glyph in ("", "", "", ""):
assert glyph in svg
def test_degree_in_sign_is_labelled():
"""Stopień W ZNAKU (nie długość absolutna): Słońce 40,21° → 10 Byka."""
svg = chartwheel.render(_chart_with_objects())
assert ">10<" in svg # Słońce
assert ">1<" in svg # Księżyc (31,45 → 1 Byka)
def test_retrograde_is_marked():
svg = chartwheel.render(_chart_with_objects())
assert "" in svg
def test_no_retrograde_marker_when_all_direct():
chart = _chart_with_objects()
for p in chart["positions"]:
p["direction"] = "D"
assert "" not in chartwheel.render(chart)
def test_objects_without_longitude_are_skipped():
"""Niekompletny obiekt nie może wywalić całego rysunku."""
chart = _chart_with_objects()
chart["positions"].append({"name": "Broken", "glyph": "?"})
svg = chartwheel.render(chart)
assert svg and "" in svg
def test_empty_when_cusps_lack_longitude():
"""Starsze wyniki bez `decimal` w cuspach — degradujemy do pustego, nie sypiemy."""
chart = _sample_chart()
for c in chart["cusps"]:
del c["decimal"]
assert chartwheel.render(chart) == ""
-134
View File
@@ -1,134 +0,0 @@
"""Zakładka „Skompiluj" — zbiorczy raport (PRE-23).
Raport składa się z trzech kawałków liczonych w różnych miejscach: horoskopu
(serwer), interpretacji natalnej i predykcji okresowych (magazyn przeglądarki).
Pilnujemy, żeby zakładka umiała je zebrać, a przede wszystkim — żeby POWIEDZIAŁA,
czego brakuje. Niekompletny raport, o którym dowiadujesz się dopiero z PDF-a,
byłby najgorszym wynikiem.
"""
import pathlib
from starlette.testclient import TestClient
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TPL = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
NATAL_JS = (APP / "static" / "natal.js").read_text(encoding="utf-8")
INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
def _client():
import os
os.environ.pop("APP_PASSWORD", None) # bez bramki logowania w teście
from app.main import app
return TestClient(app)
# ───────────────────────────────── nawigacja i trasa ─────────────────────
def test_tab_is_in_the_menu():
assert 'href="/compile"' in BASE and "Skompiluj" in BASE
def test_get_renders_empty_form():
r = _client().get("/compile")
assert r.status_code == 200
assert "Złóż podsumowanie" in r.text
def test_routes_exist():
assert '@app.get("/compile"' in MAIN and '@app.post("/compile"' in MAIN
# ─────────────────────────── kolejność w raporcie ────────────────────────
def test_report_order_matches_the_brief():
"""Prośba partnerów: najpierw imię i nazwisko, potem wprowadzone dane, potem
RYSUNEK kosmogramu, a dopiero po nim interpretacja natalna i predykcje."""
person = TPL.index('id="reportPerson"')
wheel = TPL.index("wheel_svg")
natal = TPL.index('id="reportNatal"')
preds = TPL.index('id="reportPredictions"')
assert person < wheel < natal < preds
def test_person_name_heads_the_report():
assert 'id="reportPerson"' in TPL
assert "form.person" in TPL
# ──────────────────────── zbieranie materiału z magazynów ────────────────
def test_reads_stores_through_their_api_not_raw_storage():
"""Format danych ma jednego właściciela — moduł, który je zapisuje.
Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie."""
assert "window.astrololoNatal" in JS
assert "window.astrololoPredictions" in JS
# chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu)
assert "localStorage.getItem" not in JS
assert "localStorage.setItem" not in JS
def test_stores_are_loaded_on_the_tab():
for src in ("natal.js", "predictions.js", "compile.js"):
assert src in TPL, f"brak {src} na zakładce"
def test_stores_load_before_the_assembler():
"""Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach."""
def tag(name):
return TPL.index('src="/static/' + name + '"')
assert tag("natal.js") < tag("compile.js")
assert tag("predictions.js") < tag("compile.js")
# ─────────────────────────── mówi, czego brakuje ─────────────────────────
def test_shows_what_is_missing():
assert 'id="readiness"' in TPL
assert "rdy-missing" in JS
for hint in ("Interpretacje", "Kalendarz"):
assert hint in JS, f"brak podpowiedzi, gdzie uzupełnić: {hint}"
def test_all_three_parts_are_checked():
for label in ("Policzony horoskop", "Interpretacja natalna", "Predykcje okresowe"):
assert label in JS
# ───────────────────────────── natalna zapamiętywana ─────────────────────
def test_natal_is_captured_on_interpret_tab():
assert "natal.js" in INTERPRET
assert 'id="natalNote"' in INTERPRET
def test_natal_store_keeps_a_single_entry():
"""Natalna jest JEDNA — dotyczy momentu urodzenia, nie okresu. Ponowne
wygenerowanie ma podmienić, nie dokładać."""
assert "setItem" in NATAL_JS and "push" not in NATAL_JS
def test_natal_only_stores_natal_profile():
assert "'natal'" in NATAL_JS
def test_full_storage_is_reported():
assert "pełny" in NATAL_JS
# ─────────────────────────────── bezpieczeństwo ──────────────────────────
def test_ai_text_is_escaped_before_injection():
"""Treść od modelu wstawiamy do DOM — bez ucieczki byłby to wektor wstrzyknięcia."""
assert "function esc" in JS
assert "replace(/</g, '&lt;')" in JS
def test_does_nothing_on_other_tabs():
assert "if (!document.getElementById('readiness')) return;" in JS
@@ -1,94 +0,0 @@
"""Wspólne dane formularza między zakładkami + pole imienia (PRE-21).
Znów test strukturalny na źródle: synchronizacja żyje w JS, a jej awaria jest
CICHA — formularz po prostu przestaje pamiętać dane i użytkownik wpisuje je od
nowa. Pilnujemy więc reguł, które łatwo zgubić przy kolejnych zmianach.
"""
import pathlib
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TEMPLATES = APP / "templates"
SYNC_JS = (APP / "static" / "formsync.js").read_text(encoding="utf-8")
GEO_JS = (APP / "static" / "geo.js").read_text(encoding="utf-8")
BASE_HTML = (TEMPLATES / "base.html").read_text(encoding="utf-8")
MAIN_PY = (APP / "main.py").read_text(encoding="utf-8")
WITH_FORM = ("chart.html", "interpret.html", "timeline.html")
# Pola, które mają wędrować między zakładkami. Gdy dojdzie nowe wspólne pole,
# ten wykaz ma się nie rozjechać z formsync.js.
SHARED = ("person", "date", "time", "tz_offset", "lat", "lon",
"house_system", "zodiac", "place")
# ─────────────────────────────── pole imienia ────────────────────────────
@pytest.mark.parametrize("name", WITH_FORM)
def test_person_field_present_on_every_form(name):
"""Imię i nazwisko trafia do nagłówka raportu PDF (PRE-24) — musi dać się
je wpisać z każdej zakładki, bo dane są wspólne."""
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert 'name="person"' in html, f"{name}: brak pola imienia"
@pytest.mark.parametrize("name", WITH_FORM)
def test_person_value_survives_submit(name):
"""Po wysłaniu formularza pole ma pokazywać to, co wpisano — inaczej znikałoby
przy każdym przeliczeniu."""
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert "form.person" in html, f"{name}: wartość imienia nie wraca po POST"
def test_handlers_accept_and_echo_person():
"""Każdy handler renderujący formularz przyjmuje `person` i wkłada go
z powrotem do `form` — inaczej imię znikałoby po przeliczeniu.
Cztery: Horoskop, Interpretacje, Kalendarz, Skompiluj (PRE-23)."""
assert MAIN_PY.count("person: str = Form") == 4
assert MAIN_PY.count('"person": person') == 4
# ─────────────────────── synchronizacja między zakładkami ────────────────
@pytest.mark.parametrize("key", SHARED)
def test_every_shared_field_is_synced(key):
assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane"
def test_sync_script_loaded_before_map_script():
"""KOLEJNOŚĆ MA ZNACZENIE: formsync w <head> z `defer` wykonuje się przed
geo.js (skrypty defer idą w kolejności dokumentu), więc mapa startuje już
z odtworzonymi współrzędnymi zamiast skakać po chwili."""
assert "formsync.js" in BASE_HTML, "formsync.js nie jest ładowany globalnie"
assert "defer" in BASE_HTML.split("formsync.js")[0].rsplit("<script", 1)[-1] + "defer"
def test_sync_reacts_to_other_browser_tab():
"""Zmiana w drugiej otwartej karcie ma dociągnąć się tutaj (zdarzenie storage)."""
assert "'storage'" in SYNC_JS
def test_sync_persists_on_submit():
assert "'submit'" in SYNC_JS
def test_corrupt_storage_does_not_break_the_page():
"""Uszkodzony wpis w localStorage nie może zablokować formularza."""
assert "catch" in SYNC_JS and "JSON.parse" in SYNC_JS
def test_map_changes_are_reported_to_sync():
"""geo.js ustawia pola z KODU (`.value = ...`), co samo z siebie nie wywołuje
zdarzeń — bez jawnego `change` synchronizacja przegapiłaby wybór z mapy."""
assert "function setVal" in GEO_JS
assert "dispatchEvent(new Event('change'" in GEO_JS
# wszystkie miejsca ustawiające pola idą przez setVal
assert "latEl.value =" not in GEO_JS.replace("el.value = value", "")
assert "searchEl.value = d.name" not in GEO_JS
def test_sync_does_not_wipe_fields_missing_on_a_tab():
"""Zakładka bez danego pola (np. Sygnifikatory) nie może wyczyścić wartości
zapamiętanej z innej zakładki."""
assert "if (!el) return;" in SYNC_JS
@@ -1,91 +0,0 @@
"""Zapamiętane predykcje okresowe (PRE-22).
Magazyn żyje w JS (localStorage), więc znów pilnujemy go strukturalnie. Sedno:
predykcje mają PRZEŻYĆ przejście na inną zakładkę i trafić do raportu, a lista
nie może puchnąć od powtórzonych prób na tym samym okresie.
"""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
JS = (APP / "static" / "predictions.js").read_text(encoding="utf-8")
PROGRESS = (APP / "static" / "progress.js").read_text(encoding="utf-8")
TIMELINE = (APP / "templates" / "timeline.html").read_text(encoding="utf-8")
# ─────────────────────────────── podpięcie ───────────────────────────────
def test_script_loaded_on_calendar_tab():
assert "predictions.js" in TIMELINE
def test_loaded_after_progress_script():
"""Nasłuchuje zdarzenia, które wysyła progress.js — musi być załadowany po nim,
inaczej przegapi pierwsze wywołanie."""
assert TIMELINE.index("progress.js") < TIMELINE.index("predictions.js")
def test_calendar_has_container_for_the_list():
assert 'id="savedPredictions"' in TIMELINE
def test_progress_announces_finished_horoscope():
"""Okno postępu ogłasza gotowy horoskop zdarzeniem, zamiast samo go zapisywać
— to okno postępu, a nie magazyn."""
assert "astrololo:horoscope" in PROGRESS
assert "profile" in PROGRESS.split("astrololo:horoscope")[1][:200]
# ─────────────────────────────── zachowanie ──────────────────────────────
def test_only_period_horoscopes_are_stored():
"""Interpretacja natalna ma NIE trafiać na listę predykcji okresowych."""
assert "'period'" in JS
def test_same_period_replaces_instead_of_duplicating():
"""Ponowne policzenie tego samego zakresu podmienia wpis. Inaczej lista
puchłaby przy każdej próbie z innym modelem."""
assert "function upsert" in JS
assert "p.from_date === entry.from_date" in JS
assert "p.to_date === entry.to_date" in JS
def test_entry_carries_period_and_text():
for field in ("from_date", "to_date", "text", "saved_at"):
assert field in JS, f"wpis nie niesie pola {field}"
def test_incomplete_result_is_not_stored():
"""Bez tekstu albo bez dat nie ma czego zapamiętać — nie zapisujemy śmieci."""
assert "if (!text || !from || !to) return null;" in JS
def test_list_can_be_pruned():
assert "function remove" in JS
assert "data-drop" in JS
def test_full_storage_is_reported_not_swallowed():
"""Horoskopy bywają długie. Gdy magazyn się przepełni, użytkownik musi się
o tym dowiedzieć, zamiast po cichu stracić wynik."""
assert "pełny" in JS or "pelny" in JS
assert "return false;" in JS
def test_corrupt_storage_does_not_break_the_page():
assert "catch" in JS and "Array.isArray" in JS
def test_storage_is_exposed_for_the_compile_tab():
"""Zakładka „Skompiluj" (PRE-23) ma czytać to samo miejsce — jedno źródło prawdy."""
assert "window.astrololoPredictions" in JS
def test_does_nothing_on_other_tabs():
assert "if (!document.getElementById('savedPredictions')) return;" in JS
def test_reload_does_not_duplicate():
"""Wariant bez strumienia zapisuje przy wczytaniu strony — klucz to okres,
więc odświeżenie nie mnoży wpisów."""
assert "id: from + '..' + to" in JS
@@ -1,149 +0,0 @@
"""Łącze do usługi render i wariant kosmogramu do druku (PRE-24).
Przez to łącze leci CAŁY raport: dane urodzeniowe i opisy z baz. Musi być
szyfrowane tak samo jak pozostałe, WŁASNYM kluczem — i musi obowiązywać ten sam
niezmiennik co dla klienta logiki: żadnego wyjścia w dół bez tokenu i klucza.
"""
import pathlib
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CLIENT = APP / "clients" / "render_client.py"
SRC = CLIENT.read_text(encoding="utf-8")
WHEEL = (APP / "chartwheel.py").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
# ─────────────────────────── szyfrowanie łącza ───────────────────────────
def test_uses_its_own_third_key():
"""Osobny klucz na parę rozmówców. Przejęcie klucza renderu nie może otwierać
łącza do logiki ani do danych."""
from app import link_crypto
assert link_crypto.ENV_PRESENTATION_RENDER == "LINK_KEY_PRESENTATION_RENDER"
assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_PRESENTATION_LOGIC
assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_LOGIC_DATA
assert "ENV_PRESENTATION_RENDER" in SRC
def test_report_goes_through_the_encrypted_link():
assert "link_crypto.call(" in SRC
assert "link=_link()" in SRC
def test_every_outbound_call_carries_token_and_key():
"""Ten sam niezmiennik co przy kliencie logiki — tam brak nagłówka wyszedł
dopiero na produkcji (401)."""
import ast
tree = ast.parse(SRC)
calls = [n for n in ast.walk(tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr in ("call", "call_json")]
assert calls, "nie znaleziono wywołania przez link_crypto"
for c in calls:
kwargs = {k.arg for k in c.keywords}
assert "headers" in kwargs and "link" in kwargs
def test_health_is_the_only_unencrypted_path():
"""/health musi zostać otwarty dla sond k8s — ale nic poza nim."""
from app import link_crypto
assert "/health" in link_crypto.PUBLIC_PATHS
raw = [line for line in SRC.splitlines() if "client.get(" in line or "client.post(" in line]
assert all("/health" in line for line in raw), f"surowe wywołania poza /health: {raw}"
def test_key_is_read_lazily():
"""Konfiguracja może się zmienić bez restartu procesu (podmiana sekretu)."""
assert "def _link()" in SRC and "key_from_env" in SRC
# ──────────────────────── kosmogram w wariancie do druku ─────────────────
def test_pdf_route_uses_print_theme():
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów."""
assert 'chartwheel.render(chart, theme="print")' in MAIN
def test_print_theme_has_no_css_variables():
"""`var(--…)` w SVG poza stroną nie rozwiąże się — wyszłyby czarne albo
żadne kreski."""
from app import chartwheel
chart = _sample_chart()
printed = chartwheel.render(chart, theme="print")
assert printed and "var(--" not in printed
def test_screen_theme_still_uses_css_variables():
"""Na stronie koło ma się dostrajać do motywu aplikacji."""
from app import chartwheel
assert "var(--" in chartwheel.render(_sample_chart(), theme="screen")
def test_print_theme_embeds_the_glyph_font():
"""Font glifów podaje CSS klasy `.glyph` — poza stroną trzeba go wpisać
W SAM rysunek, inaczej kosmogram w PDF wyszedłby BEZ symboli."""
from app import chartwheel
printed = chartwheel.render(_sample_chart(), theme="print")
assert 'font-family=' in printed
assert "DejaVu Sans" in printed # font obecny w obrazie usługi render
def test_screen_theme_leaves_font_to_css():
from app import chartwheel
assert "font-family=" not in chartwheel.render(_sample_chart(), theme="screen")
def test_unknown_theme_falls_back_to_screen():
from app import chartwheel
assert chartwheel.render(_sample_chart(), theme="cokolwiek") == \
chartwheel.render(_sample_chart(), theme="screen")
# ─────────────────────────── składanie po stronie klienta ────────────────
def test_browser_sends_all_three_parts():
for field in ("person", "natal", "predictions"):
assert field in COMPILE_JS, f"raport wysyłany bez części: {field}"
def test_pdf_button_exists():
tpl = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
assert 'id="pdfBtn"' in tpl
def test_wheel_failure_does_not_block_the_report():
"""Brak rysunku ma dać raport bez kosmogramu, a nie brak raportu — tekst
kosztował wywołanie modelu, rysunek policzymy zawsze."""
assert "wheel_svg = \"\"" in MAIN or 'wheel_svg, = ""' in MAIN
assert "Brak rysunku nie może zablokować raportu" in MAIN
# ─────────────────────────────────── pomocnicze ──────────────────────────
def _sample_chart():
signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
glyphs = ["", "", "", "", "", "", "", "", "", "", "", ""]
asc = 128.9
asc_sign = int(asc // 30)
return {
"angles": {"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]},
"MC": {"name": "MC", "decimal": 5.0},
"Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360},
"IC": {"name": "IC", "decimal": 185.0}},
"cusps": [{"house": i + 1, "sign": signs[(asc_sign + i) % 12],
"decimal": (asc_sign * 30 + i * 30) % 360} for i in range(12)],
"sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)],
"positions": [{"name": "Sun", "decimal": 40.2, "glyph": "", "direction": "D"}],
}
@@ -1,88 +0,0 @@
"""Poprawki zgłoszone przez partnerów: offset względem GMT i lokalizacja po
kliknięciu „Tu i teraz" (PRE-19, PRE-20).
JS-a tu nie uruchomimy, więc pilnujemy tego strukturalnie — tak samo jak
niezmienników klienta HTTP. Obie regresje są ciche (nic się nie wywala, tylko
formularz pokazuje nieprawdę), więc test na źródle jest tu wart więcej niż zwykle.
"""
import pathlib
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TEMPLATES = APP / "templates"
GEO_JS = APP / "static" / "geo.js"
WITH_OFFSET = ("chart.html", "interpret.html", "timeline.html")
# ───────────────────────────── PRE-19: offset względem GMT ───────────────
@pytest.mark.parametrize("name", WITH_OFFSET)
def test_offset_label_says_relative_to_gmt(name):
"""Etykieta musi mówić, WZGLĘDEM CZEGO liczymy przesunięcie."""
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert "Offset względem GMT" in html, f"{name}: etykieta nie mówi o GMT"
@pytest.mark.parametrize("name", WITH_OFFSET)
def test_offset_step_is_quarter_hour(name):
"""Krok 15 minut = 0,25 h. Strefy pół- i ćwierćgodzinne (Indie +5:30,
Nepal +5:45) są realne — przy kroku 1 h nie dało się ich ustawić."""
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert 'name="tz_offset" step="0.25"' in html, f"{name}: krok inny niż 15 minut"
@pytest.mark.parametrize("name", WITH_OFFSET)
def test_offset_range_covers_real_zones(name):
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert 'min="-12"' in html and 'max="14"' in html, f"{name}: brak sensownego zakresu stref"
# ──────────────────── PRE-20: „Tu i teraz" odświeża nazwę miejsca ────────
def _geo_source() -> str:
return GEO_JS.read_text(encoding="utf-8")
def _handler_body() -> str:
"""Ciało handlera zdarzenia astrololo:coords (wysyłanego przez „Tu i teraz").
Szukamy KONKRETNIE `addEventListener`, nie samej nazwy zdarzenia — ta pada
także w komentarzu nagłówkowym pliku i wtedy wycięlibyśmy komentarz zamiast
kodu (test przechodziłby albo padał z niewłaściwego powodu).
"""
src = _geo_source()
start = src.index("addEventListener('astrololo:coords'")
end = src.index("});", start)
return src[start:end]
def test_now_button_handler_refreshes_place_name():
"""REGRESJA: po „Tu i teraz" pineska i współrzędne skakały na nowe miejsce,
a w polu tekstowym zostawała stara nazwa — formularz pokazywał jedno miejsce,
a liczył dla innego."""
assert "reverseName(" in _handler_body(), (
"handler astrololo:coords nie odświeża nazwy miejsca (reverseName)"
)
def test_reverse_name_clears_field_before_lookup():
"""Nazwa jest czyszczona ZANIM przyjdzie odpowiedź. Gdyby /reverse nie
odpowiedziało (brak sieci), zostałaby stara nazwa — czyli pole kłamałoby
o lokalizacji. Lepiej puste pole niż nieprawdziwe."""
src = _geo_source()
start = src.index("function reverseName")
body = src[start:src.index("\n }", start)]
# sposób zapisu może się zmienić (bezpośrednie .value albo setVal, które
# dodatkowo zgłasza `change`) — pilnujemy KOLEJNOŚCI, nie składni
clear_at = max(body.find("searchEl.value = ''"), body.find("setVal(searchEl, '')"))
fetch_at = body.find("fetch(")
assert clear_at != -1, "reverseName nie czyści pola nazwy"
assert clear_at < fetch_at, "pole czyszczone dopiero po zapytaniu — stara nazwa zdąży zostać"
def test_now_button_still_syncs_map():
"""Odświeżenie nazwy nie może zastąpić przesunięcia pineski i widoku mapy."""
body = _handler_body()
assert "marker.setLatLng" in body and "map.setView" in body
@@ -1,64 +0,0 @@
"""Powiększanie kosmogramu na pełne okno (PRE-25).
Test strukturalny, jak przy pozostałych rzeczach żyjących w JS/CSS. Osobno
pilnujemy warstwy nakładki: to jedyna liczba w tym pliku, którą łatwo zmienić
bez zastanowienia i zepsuć coś, czego się nie widzi na pierwszy rzut oka.
"""
import pathlib
import re
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8")
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
def test_script_is_loaded_globally():
"""Ładowane w base.html, bo koło pojawi się też na zakładce Skompiluj."""
assert "wheelzoom.js" in BASE
def test_toggles_on_click():
assert "addEventListener('click'" in JS
assert "classList.toggle" in JS
def test_closes_with_escape():
assert "'Escape'" in JS
def test_reachable_from_keyboard():
"""Bez tego jedyny sposób na obejrzenie szczegółów wykresu wymagałby myszy."""
assert "tabindex" in JS
assert "'Enter'" in JS
def test_page_scroll_is_locked_while_zoomed():
"""Inaczej przewijanie „przecieka" na treść pod spodem, której i tak nie widać."""
assert "wheel-zoom-open" in JS
assert re.search(r"body\.wheel-zoom-open\s*\{[^}]*overflow:\s*hidden", CSS)
def test_zoomed_layer_sits_above_map_but_below_progress():
"""Warstwy: kontrolki Leafleta 1000 < kosmogram < okno postępu 1200.
Gdy trwa pisanie horoskopu, log operacji ma zostać na wierzchu."""
zoom = re.search(r"\.wheel-fig\.zoomed\s*\{[^}]*z-index:\s*(\d+)", CSS)
progress = re.search(r"\.progress-overlay\s*\{[^}]*z-index:\s*(\d+)", CSS)
assert zoom and progress, "nie znaleziono warstw w CSS"
assert 1000 < int(zoom.group(1)) < int(progress.group(1))
def test_zoomed_wheel_is_bounded_by_shorter_window_side():
"""Koło jest kwadratowe — ograniczamy je krótszym bokiem okna, inaczej na
szerokim ekranie wystawałoby w pionie."""
block = re.search(r"\.wheel-fig\.zoomed svg\.wheel\s*\{[^}]*\}", CSS)
assert block and "vh" in block.group(0) and "vw" in block.group(0)
def test_cursor_signals_the_affordance():
assert "zoom-in" in CSS and "zoom-out" in CSS
def test_does_nothing_without_a_wheel():
"""Na zakładkach bez kosmogramu skrypt ma po prostu wyjść."""
assert "if (!figures.length) return;" in JS
-31
View File
@@ -1,31 +0,0 @@
# Usługa render: składa raport PDF (PRE-24).
#
# Obraz jest CIĘŻKI (TeX Live) i właśnie dlatego stoi osobno — patrz app/main.py.
# Dobieramy pakiety wąsko, zamiast brać texlive-full (kilka GB):
# texlive-xetex — silnik XeLaTeX (unicode + fonty systemowe),
# texlive-latex-recommended — geometry, graphicx,
# texlive-fontspec... (w xetex) — wybór fontu systemowego,
# fonts-dejavu-core — polskie znaki ORAZ glify astrologiczne (♄ ♓ ☉),
# librsvg2-bin — rsvg-convert: SVG kosmogramu → PDF.
FROM python:3.12-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
texlive-xetex \
texlive-latex-recommended \
fonts-dejavu-core \
librsvg2-bin \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Sprawdzenie przy budowie: brak narzędzia ma wyjść TERAZ, nie przy pierwszym
# raporcie na produkcji.
RUN xelatex --version > /dev/null && rsvg-convert --version > /dev/null \
&& echo "xelatex + rsvg-convert OK"
EXPOSE 8004
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8004"]
View File
-77
View File
@@ -1,77 +0,0 @@
"""Kompilacja raportu do PDF (PRE-24).
Dwa kroki zewnętrzne, oba w katalogu tymczasowym, który po sobie sprzątamy:
1. SVG kosmogramu → PDF (`rsvg-convert`). LaTeX nie wstawia SVG wprost;
zamiana na PDF zachowuje wektor, więc rysunek zostaje ostry przy dowolnym
powiększeniu.
2. .tex → PDF (`xelatex`).
Świadomie NIE logujemy treści raportu — to dane urodzeniowe i opisy z baz.
Do logu idzie wyłącznie ogon komunikatów LaTeXa przy błędzie, bo bez niego
diagnoza jest niemożliwa.
"""
from __future__ import annotations
import logging
import pathlib
import shutil
import subprocess
import tempfile
from app.latex import build
log = logging.getLogger("astrololo.render")
WHEEL_SVG = "wheel.svg"
WHEEL_PDF = "wheel.pdf"
DOC = "report"
TIMEOUT = 120 # xelatex na dużym tekście potrzebuje chwili
class RenderError(Exception):
"""Kompilacja się nie udała — z komunikatem nadającym się do pokazania."""
def _run(cmd: list[str], cwd: pathlib.Path) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT)
def _svg_to_pdf(svg: str, work: pathlib.Path) -> str | None:
"""Zwraca nazwę pliku PDF z kosmogramem albo None, gdy nie ma czego rysować."""
if not svg or not svg.strip():
return None
(work / WHEEL_SVG).write_text(svg, encoding="utf-8")
proc = _run(["rsvg-convert", "-f", "pdf", "-o", WHEEL_PDF, WHEEL_SVG], work)
if proc.returncode != 0 or not (work / WHEEL_PDF).exists():
# Brak rysunku nie może wywalić całego raportu — tekst jest ważniejszy.
log.warning("konwersja kosmogramu nie udała się: %s", (proc.stderr or "")[-300:])
return None
return WHEEL_PDF
def to_pdf(report: dict) -> bytes:
"""Raport (dict) → bajty PDF. Rzuca RenderError, gdy LaTeX nie zbuduje pliku."""
with tempfile.TemporaryDirectory(prefix="astrololo-render-") as tmp:
work = pathlib.Path(tmp)
wheel = _svg_to_pdf(report.get("wheel_svg") or "", work)
(work / f"{DOC}.tex").write_text(build(report, wheel), encoding="utf-8")
proc = _run(
["xelatex", "-interaction=nonstopmode", "-halt-on-error", f"{DOC}.tex"],
work,
)
pdf = work / f"{DOC}.pdf"
if not pdf.exists():
tail = (proc.stdout or "")[-1200:]
log.error("xelatex nie zbudował PDF-a; ogon logu: %s", tail)
raise RenderError("LaTeX nie zbudował dokumentu. Ogon logu: " + tail[-400:])
return pdf.read_bytes()
def toolchain_ready() -> dict:
"""Czy narzędzia są na miejscu — do /health, żeby awarię obrazu widzieć od razu,
a nie przy pierwszym raporcie."""
return {
"xelatex": bool(shutil.which("xelatex")),
"rsvg-convert": bool(shutil.which("rsvg-convert")),
}
-131
View File
@@ -1,131 +0,0 @@
"""Budowa dokumentu LaTeX raportu (PRE-24).
Układ wg prośby partnerów:
1. imię i nazwisko,
2. wprowadzone dane,
3. rysunek kosmogramu,
4. interpretacja natalna,
5. predykcje okresowe.
Silnik: XeLaTeX. Nie pdflatex — tekst zawiera polskie znaki ORAZ glify
astrologiczne (♄ ♓ ☉ …), a te wymagają silnika unicode'owego z fontem systemowym.
DejaVu Sans ma jedno i drugie, dlatego jest fontem głównym.
"""
from __future__ import annotations
# Znaki, które w LaTeXu mają własne znaczenie.
#
# Odwrotnego ukośnika NIE MA na tej liście celowo. Jego zamiennik
# (`\textbackslash{}`) sam zawiera nawiasy klamrowe, więc gdyby szedł w tej samej
# pętli, kolejne podmiany uciekłyby nawiasy, które dopiero co wstawiliśmy —
# i zamiast ukośnika wyszedłby dosłowny tekst `\textbackslash\{\}`. Dlatego
# ukośnik chowamy najpierw pod znacznik zastępczy, a rozwijamy na samym końcu.
_ESCAPES = [
("{", r"\{"),
("}", r"\}"),
("$", r"\$"),
("&", r"\&"),
("#", r"\#"),
("_", r"\_"),
("%", r"\%"),
("~", r"\textasciitilde{}"),
("^", r"\textasciicircum{}"),
]
# Znak spoza tekstu użytkownika — nie da się go wpisać, więc nie da się nim
# oszukać ucieczki.
_BACKSLASH_MARK = "\x00astrololo-backslash\x00"
def esc(text: object) -> str:
"""Ucieka znaki specjalne LaTeXa.
Najgroźniejszy jest `%`: bez ucieczki komentuje RESZTĘ LINII, więc tekst od
modelu urwałby się w środku zdania i nikt by tego nie zauważył — PDF
powstałby normalnie, tylko krótszy. Dlatego to nie kosmetyka, a warunek
poprawności raportu.
"""
out = "" if text is None else str(text)
out = out.replace("\\", _BACKSLASH_MARK) # schowaj, żeby nie ucierpiał niżej
for ch, rep in _ESCAPES:
out = out.replace(ch, rep)
return out.replace(_BACKSLASH_MARK, r"\textbackslash{}")
def _paragraphs(text: str) -> str:
"""Tekst od modelu → akapity LaTeXa.
Model oddziela akapity pustą linią. Zamieniamy je na osobne akapity, a same
złamania wiersza w środku akapitu zostawiamy LaTeXowi do złożenia.
"""
blocks = [b.strip() for b in str(text or "").split("\n\n")]
return "\n\n".join(esc(b).replace("\n", " ") for b in blocks if b)
PREAMBLE = r"""\documentclass[11pt,a4paper]{article}
\usepackage{fontspec}
\usepackage[margin=2.4cm]{geometry}
\usepackage{graphicx}
% DejaVu Sans ma i polskie znaki, i glify astrologiczne — jeden font na oba
% zadania oszczędza nam dobierania fontu zapasowego.
\setmainfont{DejaVu Sans}
\setlength{\parindent}{0pt}
\setlength{\parskip}{0.6em}
\pagestyle{plain}
\begin{document}
"""
def build(report: dict, wheel_pdf: str | None = None) -> str:
"""Składa źródło .tex. `wheel_pdf` to nazwa pliku z kosmogramem (już PDF)."""
person = str(report.get("person") or "").strip()
data = report.get("data") or {}
natal = report.get("natal") or {}
predictions = report.get("predictions") or []
parts = [PREAMBLE]
# ── 1. imię i nazwisko ──────────────────────────────────────────────
parts.append(r"{\Huge\bfseries " + esc(person or "Raport astrologiczny") + r"\par}")
parts.append(r"\vspace{0.3em}")
# ── 2. wprowadzone dane ─────────────────────────────────────────────
rows = [
("Data urodzenia", data.get("date")),
("Godzina (lokalna)", data.get("time")),
("Offset względem GMT", data.get("tz_offset")),
("Szerokość", data.get("lat")),
("Długość", data.get("lon")),
("Miejsce", data.get("place")),
("System domów", data.get("house_system")),
("Zodiak", data.get("zodiac")),
]
parts.append(r"\begin{tabular}{@{}ll@{}}")
for label, value in rows:
if value not in (None, ""):
parts.append(esc(label) + r" & " + esc(value) + r"\\")
parts.append(r"\end{tabular}")
parts.append(r"\vspace{1em}")
# ── 3. kosmogram ────────────────────────────────────────────────────
if wheel_pdf:
parts.append(r"\begin{center}")
parts.append(r"\includegraphics[width=0.82\textwidth]{" + wheel_pdf + r"}")
parts.append(r"\end{center}")
parts.append(r"\vspace{0.5em}")
# ── 4. interpretacja natalna ────────────────────────────────────────
if natal.get("text"):
parts.append(r"\section*{Interpretacja natalna}")
parts.append(_paragraphs(natal["text"]))
# ── 5. predykcje okresowe ───────────────────────────────────────────
if predictions:
parts.append(r"\section*{Predykcje okresowe}")
for p in predictions:
span = f"{p.get('from_date', '')} {p.get('to_date', '')}"
parts.append(r"\subsection*{" + esc(span) + r"}")
parts.append(_paragraphs(p.get("text")))
parts.append(r"\end{document}")
return "\n".join(parts) + "\n"
-525
View File
@@ -1,525 +0,0 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
try:
return self._by_direction[direction].decrypt(
nonce, sealed, self._aad(direction, path, stamp, seq))
except InvalidTag as exc:
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
# ------------------------------------------------------------ strumień ramek
def seal_stream(self, direction: bytes, path: str, stamp: str,
chunks: Iterable[bytes]) -> Iterator[bytes]:
for seq, chunk in enumerate(chunks):
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
def open_stream(self, direction: bytes, path: str, stamp: str,
raw: bytes) -> Iterator[bytes]:
for seq, frame in enumerate(frames_in(raw)):
yield self.open(direction, path, stamp, seq, frame)
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
return b"".join(self.open_stream(direction, path, stamp, raw))
# ---------------------------------------------------------------- ramkowanie
def frame_out(payload: bytes) -> bytes:
return struct.pack(">I", len(payload)) + payload
def frames_in(raw: bytes) -> Iterator[bytes]:
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
offset = 0
while offset < len(raw):
if offset + LENGTH_PREFIX > len(raw):
raise LinkError("urwana ramka (brak nagłówka długości)")
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
offset += LENGTH_PREFIX
if offset + size > len(raw):
raise LinkError("urwana ramka (za mało danych)")
yield raw[offset:offset + size]
offset += size
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
rozjechać się między dwa odczyty.
"""
while True:
if len(buffer) < LENGTH_PREFIX:
return
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
if len(buffer) < LENGTH_PREFIX + size:
return
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
del buffer[:LENGTH_PREFIX + size]
yield frame
# ------------------------------------------------------------- świeżość ruchu
def stamp_now() -> str:
return f"{time.time():.3f}"
def check_stamp(stamp: str) -> None:
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
odtworzyć w dowolnym momencie w przyszłości."""
try:
sent = float(stamp)
except (TypeError, ValueError) as exc:
raise LinkError("brak albo błędny znacznik czasu") from exc
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
# =========================================================== strona serwerowa
class LinkCryptoMiddleware:
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
"""
def __init__(self, app, link: Link | None, layer: str) -> None:
self.app = app
self.link = link
self.layer = layer
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
return await self.app(scope, receive, send)
path = scope["path"]
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
if headers.get(HEADER_ENC.lower()) != VERSION:
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
# chcemy po cichu wrócić do jawnego ruchu.
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
self.layer, path)
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
stamp = headers.get(HEADER_TS.lower(), "")
try:
check_stamp(stamp)
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
except LinkError as exc:
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
scope = dict(scope)
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
def _sealing_send(self, send, path: str):
state: dict = {"stamp": "", "seq": 0}
async def sealing(message):
if message["type"] == "http.response.start":
state["stamp"] = stamp_now()
keep = [(k, v) for k, v in message.get("headers", [])
if k.lower() not in (b"content-length", b"content-type")]
message = dict(message)
message["headers"] = keep + [
(b"content-type", CONTENT_TYPE.encode()),
(HEADER_ENC.lower().encode(), VERSION.encode()),
(HEADER_TS.lower().encode(), state["stamp"].encode()),
]
return await send(message)
if message["type"] == "http.response.body":
chunk = message.get("body", b"")
sealed = b""
if chunk:
sealed = frame_out(self.link.seal(
RESPONSE, path, state["stamp"], state["seq"], chunk))
state["seq"] += 1
return await send({"type": "http.response.body", "body": sealed,
"more_body": message.get("more_body", False)})
return await send(message)
return sealing
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
kept.append((b"content-length", str(length).encode()))
if length:
kept.append((b"content-type", b"application/json"))
return kept
async def _read_body(receive) -> bytes:
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
raise LinkError("rozłączenie w trakcie odbioru żądania")
body += message.get("body", b"")
if not message.get("more_body", False):
return bytes(body)
def _replay(body: bytes, original):
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
"""
delivered = False
async def receive():
nonlocal delivered
if delivered:
return await original()
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return receive
async def _refuse(send, detail: str) -> None:
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
await send({"type": "http.response.start", "status": 400, "headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
]})
await send({"type": "http.response.body", "body": payload})
def install(app, env_name: str, layer: str):
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
link_key = key_from_env(env_name)
if link_key is None and encryption_required():
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
f"warstwa {layer} nie wystartuje bez klucza łącza"
)
if link_key is None:
log.warning(
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
"ruch wewnątrz sieci.", env_name, layer,
)
return None
link = Link(link_key)
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
return link
# ============================================================ strona kliencka
def call(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
się niejasnym błędem odszyfrowania.
"""
import json as _json
import httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
# sygnifikatory, o które pytamy bazę.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
)
response = client.request(method, url, json=payload, headers=request_headers)
response.raise_for_status()
return response.content
path = httpx.URL(url).path
stamp = stamp_now()
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
"Content-Type": CONTENT_TYPE})
response = client.request(method, url, content=body, headers=request_headers)
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
log.error("łącze %s odmówiło: %s", path, response.text[:200])
response.raise_for_status()
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
reply_stamp = response.headers.get(HEADER_TS, "")
check_stamp(reply_stamp)
return link.open_all(RESPONSE, path, reply_stamp, response.content)
def call_json(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None):
import json as _json
return _json.loads(call(client, method, url, payload=payload,
headers=headers, link=link))
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
w buforze zamiast zakładać, że każdy kawałek to komplet.
"""
if link is None:
yield from response.iter_bytes()
return
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
stamp = response.headers.get(HEADER_TS, "")
check_stamp(stamp)
path = response.request.url.path
buffer = bytearray()
seq = 0
for chunk in response.iter_bytes():
buffer += chunk
for frame in unframe_incremental(buffer):
yield link.open(RESPONSE, path, stamp, seq, frame)
seq += 1
if buffer:
raise LinkError("strumień urwał się w połowie ramki")
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
link: Link | None) -> Iterator[str]:
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
dopiero na znakach nowej linii.
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
działał bez zmian.
"""
import json as _json
import httpx as _httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
)
with client.stream("POST", url, json=payload, headers=request_headers) as response:
response.raise_for_status()
for text_line in response.iter_lines():
if text_line:
yield text_line
return
path = _httpx.URL(url).path
stamp = stamp_now()
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
with client.stream("POST", url, content=body, headers=request_headers) as response:
response.raise_for_status()
buffer = bytearray()
for plain in open_response_stream(response, link):
buffer += plain
while True:
nl = buffer.find(b"\n")
if nl < 0:
break
text_line = bytes(buffer[:nl])
del buffer[:nl + 1]
if text_line:
yield text_line.decode("utf-8")
if buffer:
yield bytes(buffer).decode("utf-8")
-62
View File
@@ -1,62 +0,0 @@
"""Warstwa RENDER — usługa składająca raport PDF (PRE-24).
OSOBNA usługa, nie część prezentacji, z tego samego powodu co silnik swisseph
(LOG-27): TeX Live waży setki megabajtów. Trzymanie go w obrazie produktu
spowalniałoby każdy build i deploy, a tak aktualizuje się niezależnie i awaria
renderu nie kładzie aplikacji.
Rozmawia TYLKO z prezentacją, po szyfrowanym łączu (własny, trzeci klucz —
dostaje cały raport: dane urodzeniowe i opisy z baz). W klastrze wystawiona jako
ClusterIP, bez wejścia z zewnątrz.
Świadomie nie zapisuje niczego na dysk poza katalogiem tymczasowym, który sprząta
po sobie — raport nie może zostawiać śladu w kontenerze.
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from app import link_crypto, security
from app.compile import RenderError, to_pdf, toolchain_ready
log = logging.getLogger("astrololo.render")
app = FastAPI(title="astrololo · usługa render (PDF)")
security.install(app, "render") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
link_crypto.install(app, link_crypto.ENV_PRESENTATION_RENDER, "render")
@app.post("/pdf")
def build_pdf(report: dict) -> Response:
"""Raport (JSON) → PDF. Wejście: person, data, wheel_svg, natal, predictions.
Zwracamy surowe bajty PDF-a, nie base64 — łącze i tak jest szyfrowane, więc
kodowanie tylko rozdmuchałoby odpowiedź o trzecią część.
"""
if not isinstance(report, dict):
raise HTTPException(422, "Oczekiwano obiektu JSON z raportem.")
try:
pdf = to_pdf(report)
except RenderError as e:
raise HTTPException(500, str(e))
except FileNotFoundError as e: # brak xelatex/rsvg w obrazie
raise HTTPException(503, f"Brak narzędzia w obrazie usługi render: {e}")
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'},
)
@app.get("/health")
def health() -> dict:
"""Raportuje też obecność narzędzi — dzięki temu zepsuty obraz widać od razu,
a nie dopiero przy pierwszym raporcie."""
tools = toolchain_ready()
return {"status": "ok" if all(tools.values()) else "degraded",
"layer": "render", "tools": tools}
-50
View File
@@ -1,50 +0,0 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w
prezentacji nie wystarczy — bez tego kontrolera wystarczyłoby uderzyć w logikę
z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
-4
View File
@@ -1,4 +0,0 @@
fastapi>=0.115
uvicorn[standard]>=0.34
# Szyfrowanie łącza z prezentacją (PRE-16/PRE-24): AES-256-GCM + HKDF
cryptography>=44.0
-130
View File
@@ -1,130 +0,0 @@
"""Generowanie źródła LaTeX raportu (PRE-24).
Najważniejsza część to UCIECZKA znaków specjalnych. Kompilacji tu nie
uruchomimy (brak TeX Live w tym środowisku), ale to i tak nie ona jest tu
najbardziej krucha: błędna ucieczka nie wywala kompilacji, tylko po cichu psuje
treść — najgorszy możliwy wynik, bo PDF powstaje i wygląda poprawnie.
"""
import pytest
from app.latex import build, esc
# ─────────────────────────── ucieczka znaków specjalnych ────────────────
def test_percent_is_escaped():
"""NAJGROŹNIEJSZY przypadek: `%` bez ucieczki komentuje resztę linii, więc
zdanie urywa się w środku, a PDF powstaje normalnie — tylko krótszy."""
out = esc("wzrost o 50% w tym okresie")
assert r"50\%" in out
assert "w tym okresie" in out
def test_backslash_is_escaped_first():
"""Odwrotny ukośnik musi iść pierwszy, inaczej zepsulibyśmy ucieczki
wstawione później (np. `\\%` zamieniłoby się w coś innego)."""
assert esc("a\\b") == r"a\textbackslash{}b"
def test_all_specials_are_escaped():
for ch in ("$", "&", "#", "_", "%", "{", "}"):
assert ch not in esc(ch).replace("\\" + ch, ""), f"{ch} nie zostało uciec­zone"
def test_tilde_and_caret_use_commands():
assert esc("~") == r"\textasciitilde{}"
assert esc("^") == r"\textasciicircum{}"
def test_polish_and_glyphs_pass_through():
"""XeLaTeX bierze unicode wprost — polskich znaków ani glifów nie ruszamy."""
text = "Zażółć gęślą jaźń ♄ ♓ ☉"
assert esc(text) == text
def test_escape_handles_none_and_numbers():
assert esc(None) == ""
assert esc(42) == "42"
# ─────────────────────────────── układ dokumentu ─────────────────────────
def _report(**over):
base = {
"person": "Jan Kowalski",
"data": {"date": "1984-04-30", "time": "11:20", "tz_offset": 2,
"lat": 50.0647, "lon": 19.945, "place": "Kraków",
"house_system": "whole_sign", "zodiac": "tropical"},
"natal": {"text": "Interpretacja natalna.\n\nDrugi akapit."},
"predictions": [
{"from_date": "2026-01-01", "to_date": "2026-03-31", "text": "Pierwszy kwartał."},
{"from_date": "2026-07-01", "to_date": "2026-09-30", "text": "Trzeci kwartał."},
],
}
base.update(over)
return base
def test_document_is_complete():
tex = build(_report())
assert tex.startswith(r"\documentclass")
assert tex.rstrip().endswith(r"\end{document}")
def test_uses_unicode_engine_setup():
"""XeLaTeX + fontspec — bez tego polskie znaki i glify nie wyjdą."""
tex = build(_report())
assert r"\usepackage{fontspec}" in tex
assert r"\setmainfont" in tex
def test_section_order_matches_the_brief():
"""Prośba partnerów: imię → dane → rysunek → natalna → predykcje."""
tex = build(_report(), wheel_pdf="wheel.pdf")
person = tex.index("Jan Kowalski")
data = tex.index("Data urodzenia")
wheel = tex.index("includegraphics")
natal = tex.index("Interpretacja natalna")
preds = tex.index("Predykcje okresowe")
assert person < data < wheel < natal < preds
def test_person_name_opens_the_document():
tex = build(_report())
body = tex.split(r"\begin{document}")[1]
assert body.index("Jan Kowalski") < 80, "imię ma być na samym początku"
def test_all_predictions_are_included():
tex = build(_report())
assert "2026-01-01" in tex and "2026-07-01" in tex
assert "Pierwszy kwartał" in tex and "Trzeci kwartał" in tex
def test_paragraphs_are_preserved():
"""Model oddziela akapity pustą linią — mają zostać akapitami, nie zlepkiem."""
tex = build(_report())
assert "Interpretacja natalna." in tex and "Drugi akapit." in tex
assert "Interpretacja natalna.\n\nDrugi akapit." in tex
def test_hostile_text_cannot_break_the_document():
"""Tekst od modelu jest wejściem z ZEWNĄTRZ — nie może wstrzyknąć polecenia
ani urwać dokumentu."""
nasty = r"100% \end{document} \input{/etc/passwd} $x_1$ #& {}"
tex = build(_report(natal={"text": nasty}))
body = tex.split(r"\begin{document}")[1]
assert body.count(r"\end{document}") == 1, "tekst urwał dokument"
assert r"\input{" not in body
def test_missing_parts_are_simply_absent():
"""Niekompletny materiał ma dać krótszy raport, nie wyjątek."""
tex = build({"person": "", "data": {}})
assert r"\begin{document}" in tex and r"\end{document}" in tex
assert "Raport astrologiczny" in tex # zapas, gdy brak imienia
def test_wheel_is_optional():
assert "includegraphics" not in build(_report())
assert "includegraphics" in build(_report(), wheel_pdf="wheel.pdf")