Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 114b7eebdf | |||
| 163ace4283 | |||
| f33cdc88f4 | |||
| 877ec91ff0 | |||
| 487dbb8fd3 | |||
| 5203ba9e76 | |||
| 64d1afc76d | |||
| 4da5f5fe7e | |||
| 4ef90b30bc | |||
| 73fd41b9d3 | |||
| 752f477a27 | |||
| 04b26afa6d | |||
| 2892b71be3 |
+61
-15
@@ -51,6 +51,26 @@ jobs:
|
||||
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
|
||||
run: pytest tests -q -rs
|
||||
|
||||
presentation-tests:
|
||||
name: Testy warstwy prezentacji (dostęp do baz)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: services/presentation/requirements-dev.txt
|
||||
- name: Instalacja zależności
|
||||
run: pip install -r services/presentation/requirements-dev.txt
|
||||
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
|
||||
# niż jego brak, bo daje złudzenie zabezpieczenia.
|
||||
- name: Testy (pytest)
|
||||
working-directory: services/presentation
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: pytest tests -q -rs
|
||||
|
||||
swisseph-image:
|
||||
name: Build obrazu silnika B (swisseph)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -62,25 +82,51 @@ jobs:
|
||||
- name: docker build
|
||||
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
|
||||
|
||||
- name: Smoke test (health + pozycje)
|
||||
# Test biegnie WEWNĄTRZ obrazu, bez sieci i bez kontenera w tle. Poprzednia
|
||||
# wersja startowała kontener w tle (--name swe) i pukała curl-em w
|
||||
# localhost:8003 — co miało dwie wady:
|
||||
# 1. nie sprzątała kontenera, więc każdy kolejny przebieg padał na
|
||||
# konflikcie nazwy (Conflict. The container name "/swe" is already in use),
|
||||
# 2. job Gitea Actions sam działa w kontenerze, a -p publikuje port na
|
||||
# HOŚCIE — więc localhost joba to nie ten sam localhost.
|
||||
# Wywołanie funkcji endpointów wprost omija oba problemy, a sprawdza to samo:
|
||||
# obraz się zbudował, pyswisseph liczy, kontrakt /positions się zgadza.
|
||||
# --rm gwarantuje, że nic nie zostaje po przebiegu.
|
||||
- name: Smoke test (health + pozycje) wewnątrz obrazu
|
||||
run: |
|
||||
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci
|
||||
for i in $(seq 1 30); do
|
||||
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://localhost:8003/health
|
||||
echo
|
||||
docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
|
||||
|
||||
h = health()
|
||||
assert h["status"] == "ok", h
|
||||
print("health:", h)
|
||||
|
||||
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
|
||||
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
|
||||
curl -fsS -X POST http://localhost:8003/positions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}'
|
||||
echo
|
||||
req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||
lat=50.0647, lon=19.9450)
|
||||
out = positions(req)
|
||||
by = {p["name"]: p for p in out["positions"]}
|
||||
|
||||
- name: Logi kontenera (gdy coś padło)
|
||||
if: failure()
|
||||
run: docker logs swe || true
|
||||
assert out["engine"] == "swisseph", out["engine"]
|
||||
assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
|
||||
sun = by["Sun"]["longitude"]
|
||||
assert 39.5 < sun < 41.0, f"Slonce poza oczekiwanym zakresem: {sun}"
|
||||
nn, sn = by["North Node"]["longitude"], by["South Node"]["longitude"]
|
||||
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
||||
|
||||
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
|
||||
print("SMOKE OK")
|
||||
PY
|
||||
|
||||
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
||||
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
||||
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
||||
- name: Usuń osierocony kontener ze starych przebiegów
|
||||
if: always()
|
||||
run: docker rm -f swe 2>/dev/null || true
|
||||
|
||||
compile-all:
|
||||
name: Kontrola składni wszystkich warstw
|
||||
|
||||
Binary file not shown.
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app import security
|
||||
from app.config import settings
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
from app.providers.factory import build_provider
|
||||
@@ -24,6 +25,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
||||
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
||||
|
||||
|
||||
@app.post("/search", response_model=SearchResult)
|
||||
|
||||
@@ -18,7 +18,10 @@ class SearchQuery(BaseModel):
|
||||
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||
value: str = Field(..., description="Szukana wartość.")
|
||||
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
||||
limit: int = Field(50, ge=1, le=50000)
|
||||
# Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki
|
||||
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
|
||||
# realnie potrzebuje build_report na jeden obiekt.
|
||||
limit: int = Field(50, ge=1, le=5000)
|
||||
fields: list[str] | None = Field(
|
||||
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Uwierzytelnianie międzywarstwowe (LOG-32).
|
||||
|
||||
Warstwa danych oddaje SUROWE wiersze baz — to najbardziej wrażliwy punkt całego
|
||||
systemu. Bez tego kontrolera wystarczyłoby uderzyć w nią bezpośrednio, z pominięciem
|
||||
i logiki, i logowania w 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)
|
||||
@@ -5,6 +5,7 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -12,6 +13,12 @@ import httpx
|
||||
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 {}
|
||||
|
||||
|
||||
class DataClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||
@@ -26,12 +33,12 @@ class DataClient:
|
||||
) -> dict[str, Any]:
|
||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||
r = client.post(f"{self.base_url}/search", json=payload)
|
||||
r = client.post(f"{self.base_url}/search", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.get(f"{self.base_url}/health")
|
||||
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -27,6 +27,18 @@ LUMINARIES = {"Sun", "Moon"}
|
||||
DEFAULT_ORB = 8.0
|
||||
LUMINARY_BONUS = 2.0
|
||||
|
||||
# Pary sztywno powiązane definicyjnie — kąt między nimi wynika z samej definicji
|
||||
# punktu, nie z układu nieba (SN = NN + 180°). Aspekt taki zawsze wychodzi
|
||||
# dokładny (orb 0,00°) i nie niesie żadnej informacji astrologicznej, więc
|
||||
# wycinamy go z wyników: zaśmieca listę w UI i zjada budżet promptu do LLM.
|
||||
RIGID_PAIRS = frozenset({
|
||||
frozenset({"North Node", "South Node"}),
|
||||
})
|
||||
|
||||
|
||||
def _is_rigid(name_a: str, name_b: str) -> bool:
|
||||
return frozenset({name_a, name_b}) in RIGID_PAIRS
|
||||
|
||||
|
||||
def separation(a: float, b: float) -> float:
|
||||
"""Najmniejsza separacja kątowa [0,180]."""
|
||||
@@ -56,12 +68,16 @@ def find_aspects(
|
||||
|
||||
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
|
||||
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
|
||||
|
||||
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
n = len(positions)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = positions[i], positions[j]
|
||||
if _is_rigid(a["name"], b["name"]):
|
||||
continue
|
||||
la, lb = a.get("decimal"), b.get("decimal")
|
||||
if la is None or lb is None:
|
||||
continue
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Warstwa dostawców modeli językowych (LOG-31)."""
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Kontrakt dostawcy modelu językowego (LOG-31).
|
||||
|
||||
Analogicznie do `EphemerisEngine` (LOG-24): prezentacja i reszta logiki nie wiedzą,
|
||||
kto pisze tekst — lokalny model na naszym sprzęcie czy dostawca w chmurze.
|
||||
|
||||
Kluczowa własność dla bezpieczeństwa baz (LOG-32): każdy dostawca deklaruje
|
||||
`leaves_lan`. Prompt niesie ORYGINALNE opisy z baz, więc interfejs musi jawnie
|
||||
mówić, czy ta treść opuszcza naszą sieć — UI ma na tej podstawie ostrzegać.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Completion:
|
||||
"""Wynik generowania — tekst + metryki do rozliczenia i podglądu."""
|
||||
|
||||
text: str
|
||||
model: str
|
||||
provider: str
|
||||
leaves_lan: bool
|
||||
usage: dict = field(default_factory=dict) # prompt_tokens / completion_tokens
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
"""Błąd wołania modelu — z komunikatem nadającym się do pokazania użytkownikowi."""
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
name: str = "?"
|
||||
#: czy treść promptu (a więc opisy z baz) opuszcza naszą sieć
|
||||
leaves_lan: bool = True
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||
"""Zwraca gotowy tekst. Rzuca LLMError przy niepowodzeniu."""
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> dict:
|
||||
"""Czy dostawca jest osiągalny i skonfigurowany."""
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Katalog modeli do wyboru w UI (LOG-31).
|
||||
|
||||
To są **podpowiedzi**, nie zamknięta lista. Pole modelu w UI jest tekstowe z
|
||||
datalistą, więc można wpisać dowolny identyfikator — konto może mieć dostęp do
|
||||
modeli, których tu nie ma, a nowe wychodzą szybciej, niż aktualizuje się kod.
|
||||
Puste pole = model domyślny dostawcy.
|
||||
|
||||
Uwaga o pewności danych:
|
||||
* modele **Anthropic** pochodzą z oficjalnej dokumentacji API (okna kontekstu
|
||||
i limity wyjścia zgadzają się z `app/llm/limits.py`);
|
||||
* modele **OpenAI** to podpowiedzi — nie weryfikowałem ich katalogu, więc
|
||||
traktuj je jako wygodę, a nie źródło prawdy;
|
||||
* modele **lokalne** zależą wyłącznie od tego, co masz pobrane w Ollamie/vLLM.
|
||||
|
||||
Katalog można nadpisać/rozszerzyć zmienną `<DOSTAWCA>_MODELS` (lista po przecinku),
|
||||
np. `OPENAI_MODELS="gpt-5,gpt-4o"`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from app.llm.limits import limits_for
|
||||
|
||||
# dostawca -> [(id modelu, krótki opis dla człowieka)]
|
||||
_CATALOG: dict[str, list[tuple[str, str]]] = {
|
||||
"anthropic": [
|
||||
("claude-opus-4-8", "Opus 4.8 — domyślny, bardzo zdolny, 1M kontekstu"),
|
||||
("claude-fable-5", "Fable 5 — najbardziej zdolny, do najtrudniejszych zadań"),
|
||||
("claude-sonnet-5", "Sonnet 5 — szybszy i tańszy, jakość blisko Opusa"),
|
||||
("claude-opus-4-7", "Opus 4.7 — poprzednia generacja Opusa"),
|
||||
("claude-haiku-4-5", "Haiku 4.5 — najszybszy i najtańszy, mniejsze okno"),
|
||||
],
|
||||
"openai": [
|
||||
("gpt-4o-mini", "GPT-4o mini — tani i szybki"),
|
||||
("gpt-4o", "GPT-4o"),
|
||||
("gpt-5", "GPT-5 — jeśli Twoje konto ma dostęp"),
|
||||
("gpt-4.1", "GPT-4.1"),
|
||||
("gpt-4.1-mini", "GPT-4.1 mini"),
|
||||
],
|
||||
"local": [
|
||||
("llama3.1:8b", "Llama 3.1 8B"),
|
||||
("llama3.2", "Llama 3.2"),
|
||||
("qwen2.5", "Qwen 2.5 — większe okno kontekstu"),
|
||||
("mistral", "Mistral"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def models_for(provider: str) -> list[dict]:
|
||||
"""Podpowiedzi modeli dla dostawcy, wraz z oknem kontekstu.
|
||||
|
||||
Okno kontekstu podajemy, bo wprost przekłada się na opcję „maksymalny
|
||||
kontekst modelu" — użytkownik widzi, na ile budżetu promptu może liczyć.
|
||||
"""
|
||||
override = os.getenv(f"{provider.upper()}_MODELS", "").strip()
|
||||
if override:
|
||||
entries = [(m.strip(), "") for m in override.split(",") if m.strip()]
|
||||
else:
|
||||
entries = _CATALOG.get(provider, [])
|
||||
|
||||
out = []
|
||||
for model_id, label in entries:
|
||||
context_window, max_output = limits_for(provider, model_id)
|
||||
out.append({
|
||||
"id": model_id,
|
||||
"label": label or model_id,
|
||||
"context_window": context_window,
|
||||
"max_output": max_output,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def catalog() -> dict[str, list[dict]]:
|
||||
"""Pełny katalog dla UI — jedno żądanie zamiast trzech."""
|
||||
return {provider: models_for(provider) for provider in ("local", "anthropic", "openai")}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Wybór dostawcy LLM (LOG-31) — jedyne miejsce znające konkretne implementacje.
|
||||
|
||||
Domyślny jest **model lokalny**: prompt niesie oryginalne opisy z baz, więc
|
||||
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
|
||||
przez konfigurację albo pojedyncze żądanie.
|
||||
|
||||
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
|
||||
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
|
||||
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
|
||||
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
|
||||
|
||||
Zmienne środowiskowe:
|
||||
LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
|
||||
LLM_TIMEOUT sekundy (domyślnie 120)
|
||||
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
|
||||
|
||||
<DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
|
||||
LOCAL_MODEL, LOCAL_BASE_URL (klucz zwykle zbędny)
|
||||
OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY
|
||||
ANTHROPIC_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY
|
||||
|
||||
Klucze WYŁĄCZNIE z sekretu — nigdy w repo, w UI ani w logach.
|
||||
|
||||
Zgodność wstecz: wspólne `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` nadal działają,
|
||||
ale stosują się TYLKO do dostawcy domyślnego (LLM_PROVIDER) — czyli konfiguracja
|
||||
instalacji jednodostawcowej zostaje nietknięta, a pozostali dostawcy jej nie dziedziczą.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from app.llm.base import LLMError, LLMProvider
|
||||
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||
|
||||
LOCAL = "local"
|
||||
OPENAI = "openai"
|
||||
ANTHROPIC = "anthropic"
|
||||
PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
|
||||
|
||||
_DEFAULT_MODEL = {
|
||||
LOCAL: "llama3.1:8b",
|
||||
OPENAI: "gpt-4o-mini",
|
||||
# Opus 4.8 świadomie zamiast Sonnet 5: Sonnet uruchamia myślenie adaptacyjne,
|
||||
# gdy pominąć parametr `thinking`, a jego tokeny liczą się do max_tokens —
|
||||
# przy ciasnym limicie cała tura wychodziła jako samo myślenie z pustym
|
||||
# tekstem. To była przyczyna pustych odpowiedzi na Anthropicu.
|
||||
ANTHROPIC: "claude-opus-4-8",
|
||||
}
|
||||
_DEFAULT_URL = {
|
||||
# Ollama i vLLM wystawiają zgodne API pod /v1
|
||||
LOCAL: "http://localhost:11434/v1",
|
||||
OPENAI: "https://api.openai.com/v1",
|
||||
ANTHROPIC: "https://api.anthropic.com",
|
||||
}
|
||||
|
||||
|
||||
def default_provider_name() -> str:
|
||||
return os.getenv("LLM_PROVIDER", LOCAL).lower()
|
||||
|
||||
|
||||
def max_tokens() -> int:
|
||||
return int(os.getenv("LLM_MAX_TOKENS", "2000"))
|
||||
|
||||
|
||||
def timeout() -> float:
|
||||
return float(os.getenv("LLM_TIMEOUT", "120"))
|
||||
|
||||
|
||||
def setting(provider: str, suffix: str, fallback: str = "") -> str:
|
||||
"""Ustawienie dostawcy: <DOSTAWCA>_<SUFIKS> → LLM_<SUFIKS> → wbudowana domyślna.
|
||||
|
||||
Wspólne `LLM_*` stosuje się WYŁĄCZNIE do dostawcy domyślnego — inaczej adres
|
||||
lokalnego modelu przejąłby żądania do chmury (i odwrotnie).
|
||||
"""
|
||||
specific = os.getenv(f"{provider.upper()}_{suffix}")
|
||||
if specific:
|
||||
return specific
|
||||
if provider == default_provider_name():
|
||||
generic = os.getenv(f"LLM_{suffix}")
|
||||
if generic:
|
||||
return generic
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_model(name: str | None = None, model: str | None = None) -> tuple[str, str]:
|
||||
"""(dostawca, model) BEZ budowania dostawcy — czyli bez wymogu klucza API.
|
||||
|
||||
Rozmiar budżetu promptu zależy tylko od okna kontekstu modelu, więc nie może
|
||||
zależeć od tego, czy klucz jest już skonfigurowany.
|
||||
"""
|
||||
provider = (name or default_provider_name()).lower()
|
||||
if provider not in PROVIDERS:
|
||||
provider = default_provider_name()
|
||||
chosen = (model or "").strip() or setting(provider, "MODEL", _DEFAULT_MODEL[provider])
|
||||
return provider, chosen
|
||||
|
||||
|
||||
def build_provider(name: str | None = None, model: str | None = None) -> LLMProvider:
|
||||
"""Dostawca modelu. `model` z żądania wygrywa nad konfiguracją — użytkownik
|
||||
wybiera model w UI, a konfiguracja podaje tylko wartość domyślną."""
|
||||
name = (name or default_provider_name()).lower()
|
||||
if name not in PROVIDERS:
|
||||
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
||||
|
||||
model = (model or "").strip() or setting(name, "MODEL", _DEFAULT_MODEL[name])
|
||||
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
|
||||
api_key = setting(name, "API_KEY")
|
||||
|
||||
if name in (OPENAI, ANTHROPIC) and not api_key:
|
||||
raise LLMError(
|
||||
f"Brak klucza dla dostawcy {name} — ustaw {name.upper()}_API_KEY "
|
||||
f"(z sekretu). Model lokalny klucza nie wymaga."
|
||||
)
|
||||
if name == ANTHROPIC:
|
||||
return AnthropicProvider(base_url, model, api_key, timeout())
|
||||
if name == OPENAI:
|
||||
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
|
||||
leaves_lan=True)
|
||||
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
|
||||
return ChatCompletionsProvider(LOCAL, base_url, model, api_key, timeout(),
|
||||
leaves_lan=False)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Okna kontekstu modeli i planowanie budżetu tokenów (LOG-30/31).
|
||||
|
||||
Po co to istnieje: horoskop MA powstać niezależnie od objętości promptu. Żeby to
|
||||
zagwarantować, trzeba wiedzieć dwie rzeczy o każdym modelu — ile zmieści na
|
||||
wejściu (okno kontekstu) i ile maksymalnie wypisze na wyjściu. Bez tego łatwo
|
||||
wysłać prompt, który wypełnia całe okno i **nie zostawia miejsca na odpowiedź** —
|
||||
model kończy wtedy na `max_tokens` z pustą albo uciętą treścią.
|
||||
|
||||
Zasada naczelna: **zawsze rezerwuj miejsce na odpowiedź.** Budżet promptu liczy
|
||||
się jako `okno_kontekstu − zarezerwowane_wyjście − margines`, nigdy odwrotnie.
|
||||
|
||||
Wartości są zaszyte jako rozsądne domyślne i nadpisywalne środowiskiem
|
||||
(`<DOSTAWCA>_CONTEXT_WINDOW`, `<DOSTAWCA>_MAX_OUTPUT`) — modele wychodzą szybciej,
|
||||
niż aktualizuje się ten plik.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# model -> (okno kontekstu, maksymalne wyjście) w tokenach
|
||||
_MODEL_LIMITS: dict[str, tuple[int, int]] = {
|
||||
# Anthropic
|
||||
"claude-opus-4-8": (1_000_000, 128_000),
|
||||
"claude-opus-4-7": (1_000_000, 128_000),
|
||||
"claude-opus-4-6": (1_000_000, 128_000),
|
||||
"claude-sonnet-5": (1_000_000, 128_000),
|
||||
"claude-sonnet-4-6": (1_000_000, 128_000),
|
||||
"claude-fable-5": (1_000_000, 128_000),
|
||||
"claude-haiku-4-5": (200_000, 64_000),
|
||||
# OpenAI
|
||||
"gpt-4o": (128_000, 16_384),
|
||||
"gpt-4o-mini": (128_000, 16_384),
|
||||
"gpt-4.1": (1_000_000, 32_768),
|
||||
"gpt-4.1-mini": (1_000_000, 32_768),
|
||||
# lokalne (Ollama/vLLM) — zwykle małe okno, dlatego ostrożna domyślna
|
||||
"llama3.1": (8_192, 4_096),
|
||||
"llama3.2": (8_192, 4_096),
|
||||
"qwen2.5": (32_768, 8_192),
|
||||
"mistral": (32_768, 8_192),
|
||||
}
|
||||
|
||||
# gdy modelu nie ma w tabeli — zachowawczo, żeby nie obiecywać nieistniejącego okna
|
||||
_FALLBACK: dict[str, tuple[int, int]] = {
|
||||
"anthropic": (200_000, 32_000),
|
||||
"openai": (128_000, 16_384),
|
||||
"local": (8_192, 4_096),
|
||||
}
|
||||
|
||||
# ile tokenów zostawiamy jako bufor na narzut protokołu i niedokładność liczenia
|
||||
SAFETY_MARGIN = 2_000
|
||||
# poniżej tylu tokenów wyjścia nie ma sensu wołać modelu — nie zmieści horoskopu
|
||||
MIN_OUTPUT = 1_500
|
||||
# powyżej tylu tokenów promptu ostrzegamy użytkownika (nadal pozwalając wysłać)
|
||||
WARN_PROMPT_TOKENS = 90_000
|
||||
|
||||
|
||||
def _env_int(provider: str, suffix: str) -> int | None:
|
||||
raw = os.getenv(f"{provider.upper()}_{suffix}")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def limits_for(provider: str, model: str) -> tuple[int, int]:
|
||||
"""(okno kontekstu, maksymalne wyjście) dla modelu — z nadpisaniem z ENV.
|
||||
|
||||
Dopasowanie po prefiksie, bo nazwy modeli lokalnych niosą tag (`llama3.1:8b`).
|
||||
"""
|
||||
env_ctx = _env_int(provider, "CONTEXT_WINDOW")
|
||||
env_out = _env_int(provider, "MAX_OUTPUT")
|
||||
|
||||
key = (model or "").strip().lower()
|
||||
known: tuple[int, int] | None = _MODEL_LIMITS.get(key)
|
||||
if known is None:
|
||||
for name, pair in _MODEL_LIMITS.items():
|
||||
if key.startswith(name):
|
||||
known = pair
|
||||
break
|
||||
if known is None:
|
||||
known = _FALLBACK.get(provider, _FALLBACK["local"])
|
||||
|
||||
return (env_ctx or known[0], env_out or known[1])
|
||||
|
||||
|
||||
def plan(provider: str, model: str, prompt_tokens: int,
|
||||
want_output: int | None = None) -> dict:
|
||||
"""Ile tokenów wyjścia zamówić dla promptu tej wielkości.
|
||||
|
||||
Zwraca plan z jawną diagnostyką — UI ma z czego zbudować ostrzeżenie, a błąd
|
||||
ma czym wytłumaczyć, dlaczego się nie udało.
|
||||
"""
|
||||
context_window, model_max_output = limits_for(provider, model)
|
||||
room = context_window - prompt_tokens - SAFETY_MARGIN
|
||||
target = want_output or model_max_output
|
||||
max_output = max(0, min(model_max_output, target, room))
|
||||
|
||||
warnings: list[str] = []
|
||||
if prompt_tokens > WARN_PROMPT_TOKENS:
|
||||
warnings.append(
|
||||
f"Prompt ma ~{prompt_tokens} tokenów — to dużo. Zapytanie zostanie wysłane, "
|
||||
f"ale potrwa dłużej i będzie odpowiednio kosztowne."
|
||||
)
|
||||
if max_output < MIN_OUTPUT:
|
||||
warnings.append(
|
||||
f"Po zmieszczeniu promptu zostaje tylko {max_output} tokenów na odpowiedź "
|
||||
f"(minimum {MIN_OUTPUT}). Zmniejsz budżet promptu albo wybierz model "
|
||||
f"z większym oknem kontekstu."
|
||||
)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"context_window": context_window,
|
||||
"model_max_output": model_max_output,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"max_output": max_output,
|
||||
"fits": max_output >= MIN_OUTPUT,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def prompt_token_budget(provider: str, model: str, reserve_output: int | None = None) -> int:
|
||||
"""Ile tokenów promptu wolno wysłać, ZAWSZE zostawiając miejsce na odpowiedź.
|
||||
|
||||
To jest podstawa opcji „maksymalny kontekst modelu" w UI.
|
||||
"""
|
||||
context_window, model_max_output = limits_for(provider, model)
|
||||
reserve = reserve_output or model_max_output
|
||||
return max(0, context_window - reserve - SAFETY_MARGIN)
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Implementacje dostawców LLM (LOG-31) — na samym httpx, bez SDK.
|
||||
|
||||
Świadomie bez bibliotek `openai` / `anthropic`: lokalny serwer modelu (Ollama,
|
||||
vLLM, llama.cpp) i OpenAI mówią **tym samym** protokołem `/chat/completions`,
|
||||
więc jedna implementacja obsługuje oba — różni je tylko adres i klucz. Anthropic
|
||||
ma własny kształt `/v1/messages`, stąd druga klasa. Mniej zależności, mniej
|
||||
powierzchni ataku, pełna kontrola nad tym, co wychodzi z sieci.
|
||||
|
||||
**Gwarancja niepustej odpowiedzi.** Horoskop ma powstać niezależnie od objętości
|
||||
promptu, więc `generate()` nie jest pojedynczym strzałem, tylko pętlą:
|
||||
1. wyślij turę z policzonym limitem wyjścia,
|
||||
2. jeśli model urwał na limicie — dopisz turę „kontynuuj" i sklej tekst,
|
||||
3. jeśli tura nie dała ani znaku tekstu — ponów z podpowiedzią,
|
||||
4. dopiero brak tekstu po wszystkich próbach jest błędem (z diagnostyką).
|
||||
Kontynuacja jest pewniejsza niż jedno wielkie żądanie: każda tura mieści się
|
||||
w timeoucie HTTP, a długość odpowiedzi przestaje być ograniczona jedną turą.
|
||||
|
||||
**Anthropic i myślenie.** Modele Claude potrafią mieć włączone myślenie, którego
|
||||
tokeny liczą się do `max_tokens`. Przy ciasnym limicie cała tura potrafi wyjść
|
||||
jako same bloki `thinking` z pustym tekstem — dokładnie ten objaw, który
|
||||
zgłoszono. Traktujemy taką turę jak ucięcie i kontynuujemy, zamiast zwracać pustkę.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.llm.base import Completion, LLMError, LLMProvider
|
||||
|
||||
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
# ile razy wolno poprosić model o dokończenie urwanej odpowiedzi
|
||||
MAX_CONTINUATIONS = 12
|
||||
# ile tokenów zamawiać na jedną turę — mieści się w timeoucie, a pętla i tak
|
||||
# dociągnie resztę; zbyt duża wartość ryzykuje zerwanie połączenia w trakcie
|
||||
TURN_TOKENS_CAP = 16_000
|
||||
|
||||
_CONTINUE = (
|
||||
"Kontynuuj dokładnie od miejsca, w którym przerwałeś — nie powtarzaj tego, "
|
||||
"co już napisałeś, i nie zaczynaj od nowa. Jeśli skończyłeś całą odpowiedź, "
|
||||
"napisz wyłącznie: KONIEC"
|
||||
)
|
||||
_NUDGE = (
|
||||
"Nie otrzymałem żadnej treści. Napisz odpowiedź zgodnie z powyższym poleceniem, "
|
||||
"zaczynając od razu od treści horoskopu."
|
||||
)
|
||||
_DONE_MARKER = "KONIEC"
|
||||
|
||||
|
||||
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
|
||||
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
|
||||
last: Exception | None = None
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
r = client.post(url, json=payload, headers=headers)
|
||||
if r.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS - 1:
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
if r.status_code >= 400:
|
||||
raise LLMError(f"Model odpowiedział błędem {r.status_code}: {r.text[:300]}")
|
||||
return r.json()
|
||||
except httpx.TimeoutException as e:
|
||||
last = e
|
||||
if attempt < MAX_ATTEMPTS - 1:
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
raise LLMError(
|
||||
f"Model nie odpowiedział w czasie {timeout:.0f}s. Zwiększ LLM_TIMEOUT "
|
||||
f"albo zmniejsz budżet promptu."
|
||||
) from e
|
||||
except httpx.HTTPError as e:
|
||||
last = e
|
||||
if attempt < MAX_ATTEMPTS - 1:
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
raise LLMError(f"Nie udało się połączyć z modelem: {e}") from e
|
||||
raise LLMError(f"Nie udało się wywołać modelu: {last}")
|
||||
|
||||
|
||||
def _merge_usage(total: dict, turn: dict) -> dict:
|
||||
"""Sumuje zużycie tokenów przez wszystkie tury jednej odpowiedzi."""
|
||||
for key, value in (turn or {}).items():
|
||||
if isinstance(value, int):
|
||||
total[key] = total.get(key, 0) + value
|
||||
return total
|
||||
|
||||
|
||||
def _join(parts: list[str]) -> str:
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def _explain_empty(turns: int, usage: dict, stop: str | None) -> str:
|
||||
detail = []
|
||||
if stop:
|
||||
detail.append(f"powód zakończenia: {stop}")
|
||||
for key in ("completion_tokens", "output_tokens"):
|
||||
if usage.get(key) is not None:
|
||||
detail.append(f"tokeny odpowiedzi: {usage[key]}")
|
||||
break
|
||||
suffix = f" ({', '.join(detail)})" if detail else ""
|
||||
return (
|
||||
f"Model nie zwrócił żadnej treści po {turns} próbach{suffix}. "
|
||||
f"Najczęstsza przyczyna: prompt wypełnił okno kontekstu i nie zostało miejsca "
|
||||
f"na odpowiedź. Zmniejsz budżet promptu albo wybierz model z większym oknem."
|
||||
)
|
||||
|
||||
|
||||
class _Driver:
|
||||
"""Wspólna pętla: tura → ewentualna kontynuacja → sklejony tekst.
|
||||
|
||||
Podklasy dostarczają tylko `_turn()` — reszta (kontynuacje, ponawianie pustej
|
||||
tury, sumowanie zużycia) jest identyczna dla obu protokołów.
|
||||
"""
|
||||
|
||||
name: str
|
||||
model: str
|
||||
leaves_lan: bool
|
||||
|
||||
def _turn(self, messages: list[dict], max_tokens: int):
|
||||
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate(self, prompt: str, max_tokens: int, on_event=None) -> Completion:
|
||||
"""`on_event(dict)` dostaje zdarzenia postępu — UI pokazuje z nich log.
|
||||
Raportujemy KAŻDĄ turę, bo to ona trwa; bez tego pasek postępu byłby
|
||||
ozdobnikiem, a nie informacją."""
|
||||
def emit(kind: str, message: str, **extra):
|
||||
if on_event:
|
||||
on_event({"type": kind, "message": message, **extra})
|
||||
|
||||
messages: list[dict] = [{"role": "user", "content": prompt}]
|
||||
parts: list[str] = []
|
||||
usage: dict = {}
|
||||
model_name = self.model
|
||||
remaining = max(max_tokens, 256)
|
||||
stop: str | None = None
|
||||
turns = 0
|
||||
nudged = False
|
||||
|
||||
while turns <= MAX_CONTINUATIONS:
|
||||
turns += 1
|
||||
budget = max(256, min(remaining, TURN_TOKENS_CAP))
|
||||
emit("turn_start",
|
||||
f"Tura {turns}: wysyłam do modelu {self.model} (limit {budget} tokenów)…",
|
||||
turn=turns)
|
||||
started = time.monotonic()
|
||||
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
||||
took = time.monotonic() - started
|
||||
_merge_usage(usage, turn_usage)
|
||||
|
||||
# Odejmujemy tokeny FAKTYCZNIE wyprodukowane, nie zamówiony limit tury.
|
||||
# Inaczej pierwsza tura zjadałaby cały budżet i urwana odpowiedź nigdy
|
||||
# nie doczekałaby się kontynuacji — wracałby do użytkownika fragment
|
||||
# udający całość.
|
||||
produced = (turn_usage or {}).get("completion_tokens")
|
||||
if produced is None:
|
||||
produced = (turn_usage or {}).get("output_tokens")
|
||||
if produced is None:
|
||||
produced = max(1, int(len(text) / 3.6))
|
||||
remaining -= max(1, int(produced))
|
||||
|
||||
emit("turn_end",
|
||||
f"Tura {turns}: odebrano {len(text.strip())} znaków w {took:.1f}s"
|
||||
+ (" — odpowiedź urwana, poproszę o dokończenie" if truncated else ""),
|
||||
turn=turns, chars=len(text.strip()), truncated=truncated)
|
||||
|
||||
chunk = text.strip()
|
||||
if chunk:
|
||||
if chunk.endswith(_DONE_MARKER): # model zgłasza koniec
|
||||
parts.append(("\n" if parts else "") + chunk[: -len(_DONE_MARKER)].rstrip())
|
||||
break
|
||||
parts.append(("\n" if parts else "") + chunk)
|
||||
if not truncated:
|
||||
break
|
||||
elif not truncated:
|
||||
# pusta i NIE ucięta: jedna próba z podpowiedzią, potem koniec
|
||||
if nudged or parts:
|
||||
break
|
||||
nudged = True
|
||||
messages = messages + [
|
||||
{"role": "assistant", "content": "…"},
|
||||
{"role": "user", "content": _NUDGE},
|
||||
]
|
||||
continue
|
||||
# ucięta (także tura złożona z samego myślenia) — poproś o dokończenie
|
||||
|
||||
if remaining < 256:
|
||||
break
|
||||
messages = [
|
||||
{"role": "user", "content": prompt},
|
||||
{"role": "assistant", "content": _join(parts) or "…"},
|
||||
{"role": "user", "content": _CONTINUE},
|
||||
]
|
||||
|
||||
final = _join(parts)
|
||||
if not final:
|
||||
raise LLMError(_explain_empty(turns, usage, stop))
|
||||
emit("generated", f"Gotowe: {len(final)} znaków w {turns} turach.",
|
||||
chars=len(final), turns=turns)
|
||||
|
||||
usage["turns"] = turns
|
||||
return Completion(text=final, model=model_name, provider=self.name,
|
||||
leaves_lan=self.leaves_lan, usage=usage)
|
||||
|
||||
def count_tokens(self, prompt: str) -> int:
|
||||
"""Szacunek tokenów promptu. Dostawcy z własnym licznikiem nadpisują."""
|
||||
return int(len(prompt) / 3.6)
|
||||
|
||||
|
||||
class ChatCompletionsProvider(_Driver, LLMProvider):
|
||||
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
|
||||
|
||||
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
|
||||
timeout: float = 120.0, leaves_lan: bool = True) -> None:
|
||||
self.name = name
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.leaves_lan = leaves_lan
|
||||
|
||||
def _headers(self) -> dict:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
h["Authorization"] = f"Bearer {self.api_key}"
|
||||
return h
|
||||
|
||||
def _turn(self, messages: list[dict], max_tokens: int):
|
||||
data = _post_with_retry(
|
||||
f"{self.base_url}/chat/completions", self._headers(),
|
||||
{"model": self.model, "max_tokens": max_tokens, "messages": messages},
|
||||
self.timeout,
|
||||
)
|
||||
try:
|
||||
choice = data["choices"][0]
|
||||
text = choice["message"].get("content") or ""
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||
stop = choice.get("finish_reason")
|
||||
return (text, stop == "length", data.get("usage") or {},
|
||||
data.get("model", self.model), stop)
|
||||
|
||||
def health(self) -> dict:
|
||||
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
||||
try:
|
||||
with httpx.Client(timeout=min(self.timeout, 10.0)) as client:
|
||||
r = client.get(f"{self.base_url}/models", headers=self._headers())
|
||||
info["status"] = "ok" if r.status_code < 400 else f"http {r.status_code}"
|
||||
except httpx.HTTPError as e:
|
||||
info["status"] = f"down: {e}"
|
||||
return info
|
||||
|
||||
|
||||
class AnthropicProvider(_Driver, LLMProvider):
|
||||
"""Protokół Anthropic `/v1/messages`."""
|
||||
|
||||
leaves_lan = True
|
||||
|
||||
def __init__(self, base_url: str, model: str, api_key: str = "",
|
||||
timeout: float = 120.0) -> None:
|
||||
self.name = "anthropic"
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": self.api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
|
||||
def _thinking(self) -> dict:
|
||||
"""Konfiguracja myślenia. Domyślnie adaptacyjne — podnosi jakość tekstu.
|
||||
|
||||
UWAGA: tokeny myślenia liczą się do `max_tokens`, więc przy ciasnym limicie
|
||||
cała tura potrafi wyjść jako samo myślenie z pustym tekstem. Pętla
|
||||
kontynuacji to obsługuje, ale ANTHROPIC_THINKING=off wyłącza myślenie,
|
||||
gdy zależy nam na przewidywalnym zużyciu tokenów.
|
||||
"""
|
||||
mode = os.getenv("ANTHROPIC_THINKING", "adaptive").lower()
|
||||
if mode in ("off", "disabled", "0", "false"):
|
||||
return {"thinking": {"type": "disabled"}}
|
||||
return {
|
||||
"thinking": {"type": "adaptive"},
|
||||
"output_config": {"effort": os.getenv("ANTHROPIC_EFFORT", "high")},
|
||||
}
|
||||
|
||||
def _turn(self, messages: list[dict], max_tokens: int):
|
||||
if not self.api_key:
|
||||
raise LLMError("Brak ANTHROPIC_API_KEY — dostawca anthropic wymaga klucza.")
|
||||
payload = {"model": self.model, "max_tokens": max_tokens, "messages": messages}
|
||||
payload.update(self._thinking())
|
||||
data = _post_with_retry(f"{self.base_url}/v1/messages", self._headers(),
|
||||
payload, self.timeout)
|
||||
try:
|
||||
blocks = data["content"]
|
||||
text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
||||
except (KeyError, TypeError) as e:
|
||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||
|
||||
stop = data.get("stop_reason")
|
||||
# tura złożona z samego myślenia = budżet poszedł na rozumowanie; traktujemy
|
||||
# jak ucięcie, żeby pętla poprosiła o treść zamiast zwrócić pustkę
|
||||
thinking_only = not text.strip() and any(
|
||||
b.get("type") in ("thinking", "redacted_thinking") for b in blocks
|
||||
)
|
||||
return (text, stop == "max_tokens" or thinking_only, data.get("usage") or {},
|
||||
data.get("model", self.model), stop)
|
||||
|
||||
def count_tokens(self, prompt: str) -> int:
|
||||
"""Dokładny licznik Anthropic — nie szacunek. Od tego zależy, czy po
|
||||
zmieszczeniu promptu zostanie miejsce na odpowiedź."""
|
||||
if not self.api_key:
|
||||
return super().count_tokens(prompt)
|
||||
try:
|
||||
data = _post_with_retry(
|
||||
f"{self.base_url}/v1/messages/count_tokens", self._headers(),
|
||||
{"model": self.model, "messages": [{"role": "user", "content": prompt}]},
|
||||
min(self.timeout, 30.0),
|
||||
)
|
||||
return int(data.get("input_tokens") or super().count_tokens(prompt))
|
||||
except LLMError:
|
||||
return super().count_tokens(prompt)
|
||||
|
||||
def health(self) -> dict:
|
||||
return {
|
||||
"provider": self.name, "model": self.model, "leaves_lan": True,
|
||||
"status": "ok (klucz ustawiony)" if self.api_key else "brak ANTHROPIC_API_KEY",
|
||||
}
|
||||
@@ -12,12 +12,14 @@ import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import security
|
||||
from app.clients.data_client import DataClient
|
||||
from app.models import QueryRequest, QueryResponse
|
||||
from app.service import QueryService
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||
service = QueryService()
|
||||
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
||||
|
||||
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
||||
_engine = None
|
||||
@@ -121,6 +123,239 @@ def chart_report(req: ReportRequest) -> dict:
|
||||
return {"engine": engine.name, **report}
|
||||
|
||||
|
||||
class PromptRequest(BaseModel):
|
||||
"""Wejście generatora promptu (LOG-29/30)."""
|
||||
profile: str = "natal" # natal | period
|
||||
when_utc: datetime
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
budget: str = "medium" # concise | medium | extensive | huge | max
|
||||
limit: int = 5000
|
||||
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
|
||||
model: str | None = None
|
||||
# tylko dla profilu period:
|
||||
from_date: str | None = None
|
||||
to_date: str | None = None
|
||||
techniques: list[str] | None = None
|
||||
|
||||
|
||||
@app.post("/chart/prompt")
|
||||
def chart_prompt(req: PromptRequest) -> dict:
|
||||
"""Gotowy prompt do LLM z naszych wyliczeń (LOG-29) z budżetowaniem (LOG-30).
|
||||
|
||||
profile=natal → horoskop urodzeniowy (ekran Interpretacje)
|
||||
profile=period → horoskop na wybrany okres (ekran Kalendarz)
|
||||
|
||||
Nie woła żadnego modelu — zwraca sam prompt i statystyki redukcji, żeby dało się
|
||||
go obejrzeć i skopiować. Wysyłkę do modelu doda LOG-31.
|
||||
"""
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.models import ChartMoment
|
||||
from app.prompt import CHARS_PER_TOKEN, MAX_BUDGET, build_natal_prompt, build_period_prompt
|
||||
|
||||
engine = get_engine()
|
||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||
chart = build_chart(engine, moment)
|
||||
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
|
||||
data_error = None
|
||||
|
||||
# Budżet „maksymalny kontekst modelu": limit znaków liczymy z okna kontekstu
|
||||
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
|
||||
budget_chars = None
|
||||
if req.budget == MAX_BUDGET:
|
||||
from app.llm.factory import resolve_model
|
||||
from app.llm.limits import prompt_token_budget
|
||||
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
|
||||
# więc nie może wymagać skonfigurowanego klucza API
|
||||
provider_name, model_name = resolve_model(req.provider, req.model)
|
||||
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
|
||||
|
||||
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
|
||||
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
|
||||
try:
|
||||
if req.profile == "natal":
|
||||
from app.significators import build_report
|
||||
|
||||
report: dict = {"objects": []}
|
||||
try:
|
||||
report = build_report(
|
||||
chart["positions"], DataClient(),
|
||||
aspects=chart.get("aspects"), per_object_limit=req.limit,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
data_error = f"Warstwa danych niedostępna: {e}"
|
||||
out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
|
||||
|
||||
elif req.profile == "period":
|
||||
if not (req.from_date and req.to_date):
|
||||
raise HTTPException(422, "profile=period wymaga from_date i to_date")
|
||||
from app.engine import houses as H
|
||||
from app.engine.timeline import build_timeline
|
||||
from app.significators import interpret_events
|
||||
|
||||
ramc, eps = engine.sidereal(moment)
|
||||
points = {"Asc": H.compute_asc(ramc, eps, moment.lat), "MC": H.compute_mc(ramc, eps)}
|
||||
for p in engine.positions(moment):
|
||||
points[p.name] = p.longitude
|
||||
events = build_timeline(engine, moment, points, req.from_date, req.to_date,
|
||||
req.techniques)
|
||||
try:
|
||||
interpret_events(events, DataClient())
|
||||
except httpx.HTTPError as e:
|
||||
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
||||
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
||||
req.budget, label, budget_chars)
|
||||
else:
|
||||
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
||||
except ValueError as e: # nieznany budżet
|
||||
raise HTTPException(422, str(e))
|
||||
|
||||
out["engine"] = engine.name
|
||||
if data_error:
|
||||
out["data_error"] = data_error
|
||||
return out
|
||||
|
||||
|
||||
class HoroscopeRequest(PromptRequest):
|
||||
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
|
||||
max_tokens: int | None = None
|
||||
|
||||
|
||||
@app.post("/chart/horoscope")
|
||||
def chart_horoscope(req: HoroscopeRequest) -> dict:
|
||||
"""Napisany horoskop (LOG-31): prompt z LOG-29/30 → model → gotowy tekst.
|
||||
|
||||
Zwraca TAKŻE użyty prompt — również gdy wywołanie modelu padnie — żeby dało się
|
||||
go obejrzeć i użyć ręcznie. `leaves_lan` mówi, czy treść baz opuściła naszą sieć
|
||||
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
||||
"""
|
||||
from app.llm.base import LLMError
|
||||
from app.llm.factory import build_provider
|
||||
from app.llm.limits import plan
|
||||
|
||||
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
||||
try:
|
||||
provider = build_provider(req.provider, req.model)
|
||||
|
||||
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
|
||||
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
|
||||
# w oknie kontekstu w ogóle zmieści się miejsce na horoskop.
|
||||
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||
out["token_plan"] = budget
|
||||
if budget["warnings"]:
|
||||
out["warnings"] = budget["warnings"]
|
||||
if not budget["fits"]:
|
||||
out["llm_error"] = " ".join(budget["warnings"])
|
||||
return out
|
||||
|
||||
result = provider.generate(out["prompt"], budget["max_output"])
|
||||
except LLMError as e:
|
||||
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
||||
out["llm_error"] = str(e)
|
||||
return out
|
||||
|
||||
out.update(
|
||||
horoscope=result.text,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
leaves_lan=result.leaves_lan,
|
||||
usage=result.usage,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/chart/horoscope/stream")
|
||||
def chart_horoscope_stream(req: HoroscopeRequest):
|
||||
"""To samo co /chart/horoscope, ale strumieniuje POSTĘP w trakcie pracy.
|
||||
|
||||
Pisanie horoskopu trwa minutami — bez sygnału aplikacja wygląda na zawieszoną.
|
||||
Strumień (NDJSON, jedna linia = jedno zdarzenie) niesie RZECZYWISTE etapy:
|
||||
budowę promptu, limity modelu i każdą turę generowania. Ostatnie zdarzenie
|
||||
(`result`) ma identyczny kształt co odpowiedź zwykłego endpointu.
|
||||
"""
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.progress import stream
|
||||
|
||||
def work(emit) -> dict:
|
||||
from app.llm.base import LLMError
|
||||
from app.llm.factory import build_provider
|
||||
from app.llm.limits import plan
|
||||
|
||||
emit({"type": "stage", "message": "Liczę horoskop i szukam wskazań w bazach…"})
|
||||
out = chart_prompt(req)
|
||||
st = out.get("stats", {})
|
||||
emit({"type": "stage", "message":
|
||||
f"Prompt gotowy: {st.get('chars', 0)} znaków, "
|
||||
f"wskazań {st.get('included', 0)}"
|
||||
+ (f", pominięto {st['omitted']}" if st.get("omitted") else "")})
|
||||
if out.get("data_error"):
|
||||
emit({"type": "warn", "message": out["data_error"]})
|
||||
|
||||
try:
|
||||
provider = build_provider(req.provider, req.model)
|
||||
emit({"type": "stage", "message":
|
||||
f"Dostawca: {provider.name}, model: {provider.model}"
|
||||
+ ("" if not provider.leaves_lan else " — dane opuszczają sieć")})
|
||||
|
||||
emit({"type": "stage", "message": "Liczę tokeny promptu…"})
|
||||
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||
out["token_plan"] = budget
|
||||
emit({"type": "stage", "message":
|
||||
f"Prompt {prompt_tokens} tok. · okno modelu {budget['context_window']} · "
|
||||
f"na odpowiedź {budget['max_output']}"})
|
||||
for warning in budget["warnings"]:
|
||||
emit({"type": "warn", "message": warning})
|
||||
if budget["warnings"]:
|
||||
out["warnings"] = budget["warnings"]
|
||||
if not budget["fits"]:
|
||||
out["llm_error"] = " ".join(budget["warnings"])
|
||||
return out
|
||||
|
||||
result = provider.generate(out["prompt"], budget["max_output"], on_event=emit)
|
||||
except LLMError as e:
|
||||
emit({"type": "warn", "message": f"Model zawiódł: {e}"})
|
||||
out["llm_error"] = str(e)
|
||||
return out
|
||||
|
||||
out.update(horoscope=result.text, provider=result.provider, model=result.model,
|
||||
leaves_lan=result.leaves_lan, usage=result.usage)
|
||||
return out
|
||||
|
||||
return StreamingResponse(
|
||||
stream(work),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/llm/models")
|
||||
def llm_models() -> dict:
|
||||
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
||||
|
||||
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
|
||||
dowolny identyfikator, do którego konto ma dostęp.
|
||||
"""
|
||||
from app.llm.catalog import catalog
|
||||
from app.llm.factory import _DEFAULT_MODEL
|
||||
|
||||
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
|
||||
|
||||
|
||||
@app.get("/llm/health")
|
||||
def llm_health(provider: str | None = None) -> dict:
|
||||
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
||||
from app.llm.base import LLMError
|
||||
from app.llm.factory import build_provider
|
||||
|
||||
try:
|
||||
return build_provider(provider).health()
|
||||
except LLMError as e:
|
||||
return {"status": f"blad konfiguracji: {e}"}
|
||||
|
||||
|
||||
class ProfectionsRequest(BaseModel):
|
||||
when_utc: datetime # moment urodzenia (UTC)
|
||||
lat: float = 0.0
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Strumień postępu długiej operacji (NDJSON).
|
||||
|
||||
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
|
||||
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
|
||||
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
|
||||
|
||||
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
|
||||
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
|
||||
zwykłym `fetch()` i jest trywialny do sparsowania.
|
||||
|
||||
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
|
||||
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
|
||||
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import traceback
|
||||
from collections.abc import Iterator
|
||||
from typing import Any, Callable
|
||||
|
||||
_HEARTBEAT_SECONDS = 10.0
|
||||
_DONE = object()
|
||||
|
||||
|
||||
def line(kind: str, message: str, **extra: Any) -> str:
|
||||
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
|
||||
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
|
||||
|
||||
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
|
||||
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
|
||||
— połączenie nigdy nie urywa się bez wyjaśnienia.
|
||||
"""
|
||||
events: queue.Queue = queue.Queue()
|
||||
|
||||
def emit(event: dict) -> None:
|
||||
events.put(event)
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
result = work(emit)
|
||||
events.put({"type": "result", "message": "Gotowe.", "result": result})
|
||||
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
|
||||
events.put({
|
||||
"type": "error",
|
||||
"message": f"{type(e).__name__}: {e}",
|
||||
"detail": traceback.format_exc(limit=3),
|
||||
})
|
||||
finally:
|
||||
events.put(_DONE)
|
||||
|
||||
worker = threading.Thread(target=run, daemon=True)
|
||||
worker.start()
|
||||
|
||||
while True:
|
||||
try:
|
||||
event = events.get(timeout=_HEARTBEAT_SECONDS)
|
||||
except queue.Empty:
|
||||
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
|
||||
# (proxy, load balancer) nie uznały połączenia za martwe
|
||||
yield line("ping", "…")
|
||||
continue
|
||||
if event is _DONE:
|
||||
break
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Generator promptów do LLM (LOG-29) + budżetowanie rozmiaru (LOG-30).
|
||||
|
||||
Składa z naszych wyliczeń gotowe, profesjonalne polecenie po polsku:
|
||||
- profil **natal** — horoskop urodzeniowy (na bazie /chart/report),
|
||||
- profil **period** — horoskop na wybrany okres (na bazie /chart/timeline).
|
||||
|
||||
Zasada naczelna promptu: model ma pisać WYŁĄCZNIE na podstawie dostarczonych danych
|
||||
i przy każdej tezie wskazać konkretny sygnifikator, z którego ona wynika. To odróżnia
|
||||
wynik od ogólnikowej wróżby i pozwala go zweryfikować.
|
||||
|
||||
Budżetowanie (LOG-30) — kolejność redukcji:
|
||||
1. deduplikacja (ten sam sygnifikator ORAZ ten sam opis),
|
||||
2. grupowanie identycznych opisów z licznikiem wystąpień,
|
||||
3. sortowanie malejąco wg punktacji siły (LOG-21),
|
||||
4. obcięcie ogona do budżetu — jednostką obcięcia jest CAŁE wskazanie,
|
||||
5. skracanie nadmiernie długich opisów (z jawnym oznaczeniem).
|
||||
Zawsze raportujemy, ile wskazań weszło i ile pominięto — użytkownik ma wiedzieć,
|
||||
czego brakuje, i móc zwiększyć budżet.
|
||||
|
||||
Generowanie jest deterministyczne: ten sam horoskop + ten sam budżet = ten sam prompt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# budżety w znakach (całego promptu); dobrane pod wklejanie do ChatGPT/Claude
|
||||
BUDGETS: dict[str, int] = {
|
||||
"concise": 4_000,
|
||||
"medium": 12_000,
|
||||
"extensive": 30_000,
|
||||
"huge": 120_000,
|
||||
# „maksymalny kontekst modelu" — wyliczany dynamicznie z okna kontekstu
|
||||
# wybranego modelu, ZAWSZE po odjęciu miejsca zarezerwowanego na odpowiedź.
|
||||
# Wartość poniżej jest tylko zapasem, gdy limity modelu są nieznane.
|
||||
"max": 400_000,
|
||||
}
|
||||
DEFAULT_BUDGET = "medium"
|
||||
MAX_BUDGET = "max"
|
||||
|
||||
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
|
||||
CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI
|
||||
|
||||
DISCLAIMER = (
|
||||
"Zakończ krótką notą: treść jest interpretacją astrologiczną i nie stanowi porady "
|
||||
"medycznej, prawnej ani finansowej."
|
||||
)
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", (text or "").strip().lower())
|
||||
|
||||
|
||||
def est_tokens(text: str) -> int:
|
||||
return int(len(text) / CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def _shorten(text: str, limit: int = MAX_EFFECT_CHARS) -> tuple[str, bool]:
|
||||
text = (text or "").strip()
|
||||
if len(text) <= limit:
|
||||
return text, False
|
||||
return text[:limit].rstrip() + " […skrócono]", True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- wskazania
|
||||
|
||||
def natal_indications(report: dict) -> list[dict]:
|
||||
"""Płaska lista wskazań z raportu natalnego: obiekt + faseta + opis + waga."""
|
||||
out: list[dict] = []
|
||||
for obj in report.get("objects") or []:
|
||||
name = obj.get("object")
|
||||
for facet in obj.get("facets") or []:
|
||||
score = float(facet.get("score") or 0.0)
|
||||
for s in facet.get("samples") or []:
|
||||
out.append({
|
||||
"context": f"{name} — {facet.get('label')}",
|
||||
"sort_key": (name or "", str(facet.get("label") or "")),
|
||||
"score": score,
|
||||
"significator": s.get("expanded") or s.get("significator") or "",
|
||||
"effect": s.get("effect") or "",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def period_indications(events: list[dict]) -> list[dict]:
|
||||
"""Wskazania z osi czasu — waga rośnie z liczbą trafień w bazie."""
|
||||
out: list[dict] = []
|
||||
for ev in events or []:
|
||||
head = f"{ev.get('technique')} · {ev.get('significator')} · {ev.get('exact')}"
|
||||
total = float(ev.get("interpretations_count") or 0)
|
||||
for s in ev.get("interpretations") or []:
|
||||
out.append({
|
||||
"context": head,
|
||||
"sort_key": (str(ev.get("exact") or ""), str(ev.get("technique") or "")),
|
||||
"score": total,
|
||||
"significator": s.get("expanded") or s.get("significator") or "",
|
||||
"effect": s.get("effect") or "",
|
||||
"date": ev.get("exact"),
|
||||
"start": ev.get("start"),
|
||||
"end": ev.get("end"),
|
||||
"technique": ev.get("technique"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def reduce_indications(items: list[dict], budget_chars: int) -> tuple[list[dict], dict]:
|
||||
"""Dedup → grupowanie → sortowanie wg wagi → obcięcie do budżetu.
|
||||
|
||||
Zwraca (wybrane wskazania, statystyki). Jednostką obcięcia jest całe wskazanie.
|
||||
"""
|
||||
# 1+2: dedup i grupowanie identycznych (kontekst, opis) z licznikiem
|
||||
grouped: dict[tuple[str, str], dict] = {}
|
||||
order: list[tuple[str, str]] = []
|
||||
for it in items:
|
||||
key = (_norm(it["context"]), _norm(it["effect"]))
|
||||
g = grouped.get(key)
|
||||
if g is None:
|
||||
g = {**it, "count": 0, "significators": []}
|
||||
grouped[key] = g
|
||||
order.append(key)
|
||||
g["count"] += 1
|
||||
if it["significator"] not in g["significators"]:
|
||||
g["significators"].append(it["significator"])
|
||||
merged = [grouped[k] for k in order]
|
||||
deduped = len(items) - len(merged)
|
||||
|
||||
# 3: sortowanie malejąco wg wagi; remis rozstrzygany deterministycznie
|
||||
merged.sort(key=lambda g: (-g["score"], -g["count"], g["sort_key"], g["significator"]))
|
||||
|
||||
# 5 (przed obcięciem, bo wpływa na rozmiar): skracanie długich opisów
|
||||
shortened = 0
|
||||
for g in merged:
|
||||
g["effect"], was = _shorten(g["effect"])
|
||||
shortened += 1 if was else 0
|
||||
|
||||
# 4: obcięcie ogona do budżetu — cała pozycja albo nic
|
||||
chosen: list[dict] = []
|
||||
used = 0
|
||||
for g in merged:
|
||||
cost = len(_render_indication(g)) + 1
|
||||
if used + cost > budget_chars and chosen:
|
||||
break
|
||||
chosen.append(g)
|
||||
used += cost
|
||||
|
||||
omitted = len(merged) - len(chosen)
|
||||
stats = {
|
||||
"source_rows": len(items),
|
||||
"after_grouping": len(merged),
|
||||
"deduplicated": deduped,
|
||||
"included": len(chosen),
|
||||
"omitted": omitted,
|
||||
"shortened": shortened,
|
||||
"min_score_included": round(chosen[-1]["score"], 3) if chosen else None,
|
||||
"max_score_omitted": round(merged[len(chosen)]["score"], 3) if omitted else None,
|
||||
}
|
||||
return chosen, stats
|
||||
|
||||
|
||||
def _render_indication(g: dict) -> str:
|
||||
times = f" (×{g['count']})" if g.get("count", 1) > 1 else ""
|
||||
sig = g.get("significator") or ""
|
||||
return f"- {sig} → {g.get('effect')}{times}"
|
||||
|
||||
|
||||
def _render_grouped(chosen: list[dict]) -> str:
|
||||
"""Renderuje wskazania pogrupowane po kontekście, zachowując kolejność wagi."""
|
||||
blocks: list[str] = []
|
||||
seen: dict[str, list[dict]] = {}
|
||||
order: list[str] = []
|
||||
for g in chosen:
|
||||
ctx = g["context"]
|
||||
if ctx not in seen:
|
||||
seen[ctx] = []
|
||||
order.append(ctx)
|
||||
seen[ctx].append(g)
|
||||
for ctx in order:
|
||||
rows = seen[ctx]
|
||||
blocks.append(f"## {ctx} [waga {rows[0]['score']:.2f}]")
|
||||
blocks.extend(_render_indication(g) for g in rows)
|
||||
blocks.append("")
|
||||
return "\n".join(blocks).rstrip()
|
||||
|
||||
|
||||
# ------------------------------------------------------------- sekcje danych
|
||||
|
||||
def _chart_section(chart: dict, moment_label: str | None) -> str:
|
||||
lines: list[str] = ["# DANE HOROSKOPU"]
|
||||
meta = []
|
||||
if moment_label:
|
||||
meta.append(f"moment: {moment_label}")
|
||||
if chart.get("zodiac"):
|
||||
z = chart["zodiac"]
|
||||
if chart.get("ayanamsha") is not None:
|
||||
z += f" (ayanamsa {chart['ayanamsha']:.4f}°)"
|
||||
meta.append(f"zodiak: {z}")
|
||||
if chart.get("house_system"):
|
||||
meta.append(f"system domów: {chart['house_system']}")
|
||||
if chart.get("sect"):
|
||||
meta.append(f"sekta: {'dzienna' if chart['sect'] == 'day' else 'nocna'}")
|
||||
if meta:
|
||||
lines.append(" · ".join(meta))
|
||||
|
||||
if chart.get("positions"):
|
||||
lines.append("\n## Pozycje")
|
||||
for p in chart["positions"]:
|
||||
house = f"dom {p['house']}" if p.get("house") else "—"
|
||||
lines.append(f"{p['name']:<12} {p.get('in_sign',''):<16} {house:<8} {p.get('direction','')}")
|
||||
|
||||
angles = chart.get("angles") or {}
|
||||
if angles:
|
||||
lines.append("\n## Osie")
|
||||
for key in ("Asc", "MC", "Dsc", "IC"):
|
||||
a = angles.get(key)
|
||||
if a:
|
||||
lines.append(f"{a['name']:<5} {a.get('in_sign','')}")
|
||||
|
||||
if chart.get("lots"):
|
||||
lines.append("\n## Lots (punkty arabskie)")
|
||||
for lot in chart["lots"]:
|
||||
lines.append(
|
||||
f"{lot['name']:<10} {lot.get('in_sign',''):<16} dom {lot.get('house','—')}"
|
||||
f" ({lot.get('formula','')})"
|
||||
)
|
||||
|
||||
if chart.get("aspects"):
|
||||
lines.append("\n## Aspekty (orb; A = aplikacyjny, S = separacyjny)")
|
||||
for a in chart["aspects"]:
|
||||
mark = a.get("as") or ""
|
||||
lines.append(
|
||||
f"{a['obj1']} {a['aspect']} {a['obj2']} orb {a.get('orb', 0):.2f}° {mark}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _indications_section(chosen: list[dict], stats: dict) -> str:
|
||||
if not chosen:
|
||||
return (
|
||||
"# WSKAZANIA Z BAZ\n"
|
||||
"(brak trafień w bazach dla tego horoskopu — oprzyj interpretację wyłącznie "
|
||||
"na danych horoskopu powyżej)"
|
||||
)
|
||||
head = [
|
||||
"# WSKAZANIA Z BAZ INTERPRETACYJNYCH",
|
||||
"Wskazania dopasowane do tego horoskopu, uporządkowane od najsilniejszych.",
|
||||
"Zapis: `- sygnifikator → opis (×ile razy wystąpiło w bazach)`.",
|
||||
]
|
||||
if stats.get("omitted"):
|
||||
head.append(
|
||||
f"UWAGA: pokazano {stats['included']} najsilniejszych wskazań, pominięto "
|
||||
f"{stats['omitted']} słabszych (limit długości)."
|
||||
)
|
||||
return "\n".join(head) + "\n\n" + _render_grouped(chosen)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ prompty
|
||||
|
||||
_NATAL_TASK = """# ZADANIE
|
||||
Jesteś doświadczonym astrologiem. Napisz profesjonalną interpretację HOROSKOPU
|
||||
URODZENIOWEGO po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||
|
||||
_PERIOD_TASK = """# ZADANIE
|
||||
Jesteś doświadczonym astrologiem. Napisz profesjonalną prognozę astrologiczną
|
||||
NA WYBRANY OKRES po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||
|
||||
_NATAL_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||
1. Struktura: (a) portret ogólny, (b) temperament i sekta, (c) obszary życia według domów,
|
||||
(d) napięcia i wyzwania, (e) zasoby i mocne strony, (f) zwięzłe podsumowanie.
|
||||
2. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie, np. „(Moon in 12th house)".
|
||||
Teza bez wskazania jest niedopuszczalna.
|
||||
3. Nie dodawaj twierdzeń, których nie da się wywieść z powyższych danych. Nie zmyślaj
|
||||
pozycji, aspektów ani wskazań; nie korzystaj z wiedzy spoza tego promptu.
|
||||
4. Gdy wskazania są sprzeczne, powiedz to wprost i wskaż obie strony, zamiast wybierać jedną.
|
||||
5. Wagę wskazania traktuj jako siłę świadectwa — mocniejsze mają pierwszeństwo w syntezie.
|
||||
6. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||
|
||||
_PERIOD_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||
1. Uporządkuj prognozę CHRONOLOGICZNIE; przy każdym okresie podaj daty (start / dokładna / koniec).
|
||||
2. Dla każdej daty napisz, która technika ją wyznacza (profekcja, solariusz, dyrekcja solar-arc,
|
||||
Firdaria) i co z niej wynika.
|
||||
3. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie. Teza bez wskazania jest
|
||||
niedopuszczalna.
|
||||
4. Nie zmyślaj dat ani zdarzeń spoza podanych. Nie korzystaj z wiedzy spoza tego promptu.
|
||||
5. Rozróżniaj okresy o mocnym świadectwie (wysoka waga, kilka technik zbieżnych w czasie)
|
||||
od słabych — i powiedz wprost, które są które.
|
||||
6. Na końcu dodaj krótkie zestawienie: najważniejsze okresy w kolejności ważności.
|
||||
7. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||
|
||||
|
||||
def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str:
|
||||
return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"])
|
||||
|
||||
|
||||
def _budget_chars(budget: str, budget_chars: int | None = None) -> int:
|
||||
"""Limit znaków promptu. `budget_chars` nadpisuje tabelę — używane dla opcji
|
||||
„maksymalny kontekst modelu", gdzie limit zależy od wybranego modelu i musi
|
||||
być policzony po odjęciu miejsca zarezerwowanego na odpowiedź."""
|
||||
if budget not in BUDGETS:
|
||||
raise ValueError(
|
||||
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
|
||||
)
|
||||
if budget_chars and budget_chars > 0:
|
||||
return budget_chars
|
||||
return BUDGETS[budget]
|
||||
|
||||
|
||||
def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) -> dict:
|
||||
stats = {
|
||||
**stats,
|
||||
"profile": profile,
|
||||
"budget": budget,
|
||||
"limit_chars": limit,
|
||||
"chars": len(prompt),
|
||||
"est_tokens": est_tokens(prompt),
|
||||
}
|
||||
return {"profile": profile, "prompt": prompt, "stats": stats}
|
||||
|
||||
|
||||
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
|
||||
moment_label: str | None = None,
|
||||
budget_chars: int | None = None) -> dict:
|
||||
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
|
||||
limit = _budget_chars(budget, budget_chars)
|
||||
chart_sec = _chart_section(chart, moment_label)
|
||||
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
|
||||
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
|
||||
prompt = _assemble(_NATAL_TASK, chart_sec, _indications_section(chosen, stats), _NATAL_OUTPUT)
|
||||
return _finish(prompt, budget, limit, stats, "natal")
|
||||
|
||||
|
||||
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
|
||||
budget: str = DEFAULT_BUDGET, moment_label: str | None = None,
|
||||
budget_chars: int | None = None) -> dict:
|
||||
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
|
||||
limit = _budget_chars(budget, budget_chars)
|
||||
chart_sec = _chart_section(chart, moment_label)
|
||||
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
|
||||
|
||||
ev_lines = ["# OŚ CZASU (techniki predykcyjne)",
|
||||
"Zapis: technika · sygnifikator · start → dokładna → koniec."]
|
||||
for ev in events or []:
|
||||
ev_lines.append(
|
||||
f"- {ev.get('technique')} · {ev.get('significator')} · "
|
||||
f"{ev.get('start')} → {ev.get('exact')} → {ev.get('end')}"
|
||||
)
|
||||
events_sec = "\n".join(ev_lines)
|
||||
|
||||
fixed = len(task) + len(chart_sec) + len(events_sec) + len(_PERIOD_OUTPUT) + len(DISCLAIMER) + 200
|
||||
chosen, stats = reduce_indications(period_indications(events), max(limit - fixed, 500))
|
||||
body = events_sec + "\n\n" + _indications_section(chosen, stats)
|
||||
prompt = _assemble(task, chart_sec, body, _PERIOD_OUTPUT)
|
||||
stats["events"] = len(events or [])
|
||||
return _finish(prompt, budget, limit, stats, "period")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""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)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
||||
from app.engine.aspects import find_aspects, separation
|
||||
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
|
||||
|
||||
|
||||
def test_separation_wraparound():
|
||||
@@ -55,6 +55,28 @@ def test_separating_when_moving_apart():
|
||||
assert a["applying"] is False and a["as"] == "S"
|
||||
|
||||
|
||||
def test_rigid_pair_nodes_not_reported():
|
||||
# SN = NN + 180° z definicji — trywialna opozycja, nie aspekt
|
||||
pos = [
|
||||
{"name": "North Node", "decimal": 42.0, "speed": -0.053},
|
||||
{"name": "South Node", "decimal": 222.0, "speed": -0.053},
|
||||
{"name": "Sun", "decimal": 42.5, "speed": 0.96},
|
||||
]
|
||||
pairs = {frozenset((a["obj1"], a["obj2"])) for a in find_aspects(pos)}
|
||||
assert frozenset({"North Node", "South Node"}) not in pairs
|
||||
# aspekty węzłów do innych obiektów zostają nietknięte
|
||||
assert frozenset({"Sun", "North Node"}) in pairs
|
||||
assert frozenset({"Sun", "South Node"}) in pairs
|
||||
|
||||
|
||||
def test_no_rigid_pairs_in_real_chart(own_engine, reference_moment):
|
||||
from app.engine.chart import build_chart
|
||||
|
||||
chart = build_chart(own_engine, reference_moment)
|
||||
found = [a for a in chart["aspects"] if frozenset((a["obj1"], a["obj2"])) in RIGID_PAIRS]
|
||||
assert not found, f"trywialne aspekty par sztywnych w horoskopie: {found}"
|
||||
|
||||
|
||||
def test_no_as_flag_without_speeds():
|
||||
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||
assert "as" not in find_aspects(pos)[0]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Okna kontekstu i planowanie budżetu tokenów (LOG-30/31).
|
||||
|
||||
Naczelna zasada, której pilnują te testy: **zawsze zostaje miejsce na odpowiedź**.
|
||||
Prompt nigdy nie może wypełnić całego okna kontekstu, bo wtedy model kończy na
|
||||
`max_tokens` z pustą albo uciętą treścią — to był zgłoszony błąd.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.llm import limits
|
||||
|
||||
|
||||
def test_known_models_have_documented_limits():
|
||||
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
|
||||
assert ctx == 1_000_000 and out == 128_000
|
||||
ctx, out = limits.limits_for("anthropic", "claude-haiku-4-5")
|
||||
assert ctx == 200_000 and out == 64_000
|
||||
|
||||
|
||||
def test_local_model_tag_is_matched_by_prefix():
|
||||
"""Modele lokalne niosą tag (`llama3.1:8b`) — dopasowanie musi to znieść."""
|
||||
assert limits.limits_for("local", "llama3.1:8b") == limits.limits_for("local", "llama3.1")
|
||||
|
||||
|
||||
def test_unknown_model_falls_back_conservatively():
|
||||
ctx, out = limits.limits_for("local", "jakis-egzotyczny-model")
|
||||
assert ctx == 8_192 and out == 4_096
|
||||
|
||||
|
||||
def test_env_overrides_win(monkeypatch):
|
||||
"""Modele wychodzą szybciej, niż aktualizuje się tabela."""
|
||||
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "131072")
|
||||
monkeypatch.setenv("LOCAL_MAX_OUTPUT", "8192")
|
||||
assert limits.limits_for("local", "llama3.1:8b") == (131_072, 8_192)
|
||||
|
||||
|
||||
def test_env_override_ignores_garbage(monkeypatch):
|
||||
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "nie-liczba")
|
||||
assert limits.limits_for("local", "llama3.1:8b")[0] == 8_192
|
||||
|
||||
|
||||
# ------------------------------------------------- rezerwa miejsca na odpowiedź
|
||||
|
||||
def test_prompt_budget_always_reserves_room_for_answer():
|
||||
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
|
||||
budget = limits.prompt_token_budget("anthropic", "claude-opus-4-8")
|
||||
assert budget + out + limits.SAFETY_MARGIN <= ctx
|
||||
assert budget > 0
|
||||
|
||||
|
||||
def test_small_context_model_still_leaves_room():
|
||||
budget = limits.prompt_token_budget("local", "llama3.1:8b")
|
||||
ctx, out = limits.limits_for("local", "llama3.1:8b")
|
||||
assert budget + out + limits.SAFETY_MARGIN <= ctx
|
||||
|
||||
|
||||
def test_plan_shrinks_output_when_prompt_is_huge():
|
||||
"""Duży prompt nie może dostać pełnego okna wyjścia — musi się zmieścić."""
|
||||
ctx, model_out = limits.limits_for("local", "llama3.1:8b")
|
||||
p = limits.plan("local", "llama3.1:8b", prompt_tokens=6_000)
|
||||
assert p["max_output"] <= ctx - 6_000 - limits.SAFETY_MARGIN
|
||||
assert p["max_output"] < model_out
|
||||
|
||||
|
||||
def test_plan_reports_not_fitting_instead_of_failing_silently():
|
||||
p = limits.plan("local", "llama3.1:8b", prompt_tokens=8_000)
|
||||
assert p["fits"] is False
|
||||
assert any("odpowied" in w for w in p["warnings"])
|
||||
|
||||
|
||||
def test_plan_warns_above_90k_but_still_fits():
|
||||
"""Powyżej 90 tys. tokenów ostrzegamy — ale wysłanie MA być nadal możliwe."""
|
||||
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=120_000)
|
||||
assert p["fits"] is True, "duży prompt nadal musi dać się wysłać"
|
||||
assert p["max_output"] >= limits.MIN_OUTPUT
|
||||
assert any("dużo" in w for w in p["warnings"])
|
||||
|
||||
|
||||
def test_no_warning_below_threshold():
|
||||
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=10_000)
|
||||
assert p["warnings"] == [] and p["fits"] is True
|
||||
|
||||
|
||||
def test_requested_output_is_capped_by_model_maximum():
|
||||
p = limits.plan("anthropic", "claude-haiku-4-5", prompt_tokens=1_000, want_output=999_999)
|
||||
assert p["max_output"] == 64_000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tokens", [0, 1_000, 50_000, 200_000, 900_000])
|
||||
def test_plan_never_returns_negative_output(tokens):
|
||||
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=tokens)
|
||||
assert p["max_output"] >= 0
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Dostawcy LLM (LOG-31) — bez wołania jakiegokolwiek prawdziwego modelu.
|
||||
|
||||
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
|
||||
deterministyczne i nic nie wychodzi na zewnątrz.
|
||||
"""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.llm import factory
|
||||
from app.llm.base import Completion, LLMError
|
||||
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||
|
||||
|
||||
def _mock_client(handler):
|
||||
"""Podmienia httpx.Client na wersję z transportem testowym."""
|
||||
class _C(httpx.Client):
|
||||
def __init__(self, *a, **kw):
|
||||
kw["transport"] = httpx.MockTransport(handler)
|
||||
super().__init__(*a, **kw)
|
||||
return _C
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_ok(monkeypatch):
|
||||
def handler(request):
|
||||
assert request.url.path.endswith("/chat/completions")
|
||||
return httpx.Response(200, json={
|
||||
"model": "test-model",
|
||||
"choices": [{"message": {"content": "Horoskop testowy."}}],
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 50},
|
||||
})
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
|
||||
|
||||
# ------------------------------------------------------- protokół chat/completions
|
||||
|
||||
def test_local_provider_generates(chat_ok):
|
||||
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||
out = p.generate("prompt", 500)
|
||||
assert isinstance(out, Completion)
|
||||
assert out.text == "Horoskop testowy."
|
||||
assert out.usage["completion_tokens"] == 50
|
||||
|
||||
|
||||
def test_local_provider_does_not_leave_lan(chat_ok):
|
||||
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||
assert p.generate("prompt", 100).leaves_lan is False
|
||||
|
||||
|
||||
def test_cloud_provider_marks_leaving_lan(chat_ok):
|
||||
p = ChatCompletionsProvider("openai", "https://api.openai.com/v1", "m", "klucz",
|
||||
leaves_lan=True)
|
||||
assert p.generate("prompt", 100).leaves_lan is True
|
||||
|
||||
|
||||
def test_api_key_sent_only_when_set(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["auth"] = request.headers.get("authorization")
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}]})
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||
assert seen["auth"] is None
|
||||
ChatCompletionsProvider("openai", "http://x/v1", "m", "tajny").generate("p", 10)
|
||||
assert seen["auth"] == "Bearer tajny"
|
||||
|
||||
|
||||
def test_http_error_becomes_readable_message(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "Client",
|
||||
_mock_client(lambda r: httpx.Response(400, text="zly model")))
|
||||
with pytest.raises(LLMError, match="400"):
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||
|
||||
|
||||
def test_malformed_response_reported(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "Client",
|
||||
_mock_client(lambda r: httpx.Response(200, json={"nonsens": 1})))
|
||||
with pytest.raises(LLMError, match="kształt"):
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||
|
||||
|
||||
def test_retries_then_succeeds(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
return httpx.Response(429, text="za duzo")
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
monkeypatch.setattr("app.llm.providers.time.sleep", lambda s: None) # bez czekania
|
||||
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10).text == "ok"
|
||||
assert calls["n"] == 3
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- Anthropic
|
||||
|
||||
def test_anthropic_generates(monkeypatch):
|
||||
def handler(request):
|
||||
assert request.url.path.endswith("/v1/messages")
|
||||
assert request.headers.get("x-api-key") == "klucz"
|
||||
assert request.headers.get("anthropic-version")
|
||||
return httpx.Response(200, json={
|
||||
"model": "claude-x",
|
||||
"content": [{"type": "text", "text": "Prognoza."}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
})
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
out = AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
|
||||
assert out.text == "Prognoza." and out.leaves_lan is True
|
||||
|
||||
|
||||
def test_anthropic_requires_key():
|
||||
with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"):
|
||||
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- fabryka
|
||||
|
||||
def test_default_provider_is_local(monkeypatch):
|
||||
monkeypatch.delenv("LLM_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("LLM_BASE_URL", raising=False)
|
||||
p = factory.build_provider()
|
||||
assert p.name == "local"
|
||||
# domyślnie NIC nie opuszcza sieci — prompt niesie opisy z baz (LOG-32)
|
||||
assert p.leaves_lan is False
|
||||
|
||||
|
||||
def test_openai_requires_key(monkeypatch):
|
||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
|
||||
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
|
||||
factory.build_provider("openai")
|
||||
|
||||
|
||||
def test_unknown_provider_rejected():
|
||||
with pytest.raises(LLMError, match="Nieznany dostawca"):
|
||||
factory.build_provider("bzdura")
|
||||
|
||||
|
||||
def test_env_overrides_model_and_url(monkeypatch):
|
||||
monkeypatch.setenv("LLM_MODEL", "moj-model")
|
||||
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
|
||||
p = factory.build_provider("local")
|
||||
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
|
||||
|
||||
|
||||
# ---------------------------------------- konfiguracja per dostawca (regresja LOG-31)
|
||||
# UI pozwala przelaczac dostawce przy kazdym zadaniu, wiec ustawienia JEDNEGO nie moga
|
||||
# przeciekac na pozostalych. Wczesniej wspolne LLM_BASE_URL/LLM_MODEL kierowaly zadania
|
||||
# do OpenAI na adres lokalnej Ollamy i prosily Anthropic o model llama.
|
||||
|
||||
def _clear(monkeypatch):
|
||||
for v in ("LLM_PROVIDER", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_KEY",
|
||||
"LOCAL_MODEL", "LOCAL_BASE_URL", "LOCAL_API_KEY",
|
||||
"OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY",
|
||||
"ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
|
||||
monkeypatch.delenv(v, raising=False)
|
||||
|
||||
|
||||
def test_local_config_does_not_leak_to_cloud(monkeypatch):
|
||||
"""Sedno bledu: skonfigurowany model lokalny przejmowal zadania do chmury."""
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||
monkeypatch.setenv("LLM_BASE_URL", "http://ollama:11434/v1") # konfiguracja lokalnego
|
||||
monkeypatch.setenv("LLM_MODEL", "llama3.1:8b")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
|
||||
local = factory.build_provider("local")
|
||||
assert local.base_url == "http://ollama:11434/v1" and local.model == "llama3.1:8b"
|
||||
|
||||
openai = factory.build_provider("openai")
|
||||
assert openai.base_url == "https://api.openai.com/v1", "zadanie do OpenAI poszloby do Ollamy"
|
||||
assert openai.model == "gpt-4o-mini", "OpenAI dostalby nazwe modelu llama"
|
||||
|
||||
|
||||
def test_provider_specific_settings_win(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
|
||||
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
|
||||
p = factory.build_provider("anthropic")
|
||||
assert p.model == "claude-opus-4-8" and p.api_key == "sk-ant"
|
||||
|
||||
|
||||
def test_generic_vars_apply_only_to_default_provider(monkeypatch):
|
||||
"""Zgodnosc wstecz: wspolne LLM_* konfiguruja dostawce domyslnego i tylko jego."""
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("LLM_PROVIDER", "openai")
|
||||
monkeypatch.setenv("LLM_API_KEY", "sk-generic")
|
||||
monkeypatch.setenv("LLM_MODEL", "gpt-4o")
|
||||
assert factory.build_provider("openai").model == "gpt-4o"
|
||||
assert factory.build_provider("local").model == "llama3.1:8b" # nie dziedziczy
|
||||
|
||||
|
||||
def test_cloud_without_key_is_rejected_clearly(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||
for name in ("openai", "anthropic"):
|
||||
with pytest.raises(LLMError, match=f"{name.upper()}_API_KEY"):
|
||||
factory.build_provider(name)
|
||||
|
||||
|
||||
def test_local_needs_no_key(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
assert factory.build_provider("local").api_key == ""
|
||||
|
||||
|
||||
# ------------------------------------------- pusta odpowiedz modelu (cicha awaria)
|
||||
# Regresja: model potrafi oddac pusta tresc (prompt zjadl caly kontekst ->
|
||||
# finish_reason=length, completion_tokens=0). Wczesniej generate() zwracalo pusty
|
||||
# tekst BEZ bledu, widok nic nie renderowal i uzytkownik dostawal pusta strone
|
||||
# bez zadnego wyjasnienia. Pusta odpowiedz MUSI byc bledem.
|
||||
|
||||
def test_empty_completion_raises_instead_of_silent_blank(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||
"model": "llama3.1:8b",
|
||||
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
|
||||
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
|
||||
})))
|
||||
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
|
||||
|
||||
|
||||
def test_empty_completion_explains_context_window(monkeypatch):
|
||||
"""Komunikat ma prowadzic do przyczyny, a nie tylko stwierdzac fakt."""
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": " "}, "finish_reason": "length"}],
|
||||
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
|
||||
})))
|
||||
with pytest.raises(LLMError) as ei:
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
|
||||
msg = str(ei.value)
|
||||
assert "kontekstu" in msg and "budżet" in msg
|
||||
assert "powód zakończenia: length" in msg # diagnostyka w tresci bledu
|
||||
|
||||
|
||||
def test_whitespace_only_is_treated_as_empty(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": "\n\n \t "}}],
|
||||
})))
|
||||
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100)
|
||||
|
||||
|
||||
def test_anthropic_empty_completion_raises(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||
"model": "claude-x", "content": [], "stop_reason": "max_tokens",
|
||||
"usage": {"input_tokens": 9000, "output_tokens": 0},
|
||||
})))
|
||||
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
|
||||
AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
|
||||
|
||||
|
||||
def test_normal_response_still_passes(monkeypatch):
|
||||
"""Straznik nie moze psuc poprawnej odpowiedzi."""
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": "Horoskop."}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
|
||||
})))
|
||||
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100).text == "Horoskop."
|
||||
|
||||
|
||||
# ------------------------------------- kontynuacja: horoskop MA powstac zawsze
|
||||
# Sedno wymagania: niezaleznie od objetosci promptu i limitu wyjscia, pelna tresc
|
||||
# ma wrocic do uzytkownika. Model urwany na max_tokens jest proszony o dokonczenie
|
||||
# w ramach tej samej rozmowy, a kawalki sa sklejane.
|
||||
|
||||
def _scripted(responses):
|
||||
"""Transport oddajacy kolejne odpowiedzi z listy (po jednej na ture)."""
|
||||
seq = list(responses)
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json=seq.pop(0) if seq else seq_last)
|
||||
|
||||
seq_last = responses[-1]
|
||||
return handler, seen
|
||||
|
||||
|
||||
def _chat(text, finish):
|
||||
return {"choices": [{"message": {"content": text}, "finish_reason": finish}],
|
||||
"usage": {"completion_tokens": 10}}
|
||||
|
||||
|
||||
def test_truncated_answer_is_continued_and_joined(monkeypatch):
|
||||
handler, seen = _scripted([
|
||||
_chat("Czesc pierwsza.", "length"),
|
||||
_chat("Czesc druga. KONIEC", "stop"),
|
||||
])
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
|
||||
assert "Czesc pierwsza." in out.text and "Czesc druga." in out.text
|
||||
assert "KONIEC" not in out.text # znacznik nie trafia do horoskopu
|
||||
assert out.usage["turns"] == 2
|
||||
|
||||
|
||||
def test_continuation_asks_in_same_conversation(monkeypatch):
|
||||
"""Kontynuacja musi isc jako kolejna tura rozmowy, a ostatnia wiadomosc MUSI
|
||||
byc od uzytkownika — Claude odrzuca prefill w turze asystenta (400)."""
|
||||
handler, seen = _scripted([_chat("Poczatek", "length"), _chat("Reszta", "stop")])
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
|
||||
msgs = json.loads(seen[1].content)["messages"]
|
||||
assert msgs[-1]["role"] == "user", "ostatnia wiadomosc nie moze byc prefillem asystenta"
|
||||
assert msgs[1]["role"] == "assistant" and "Poczatek" in msgs[1]["content"]
|
||||
|
||||
|
||||
def test_anthropic_thinking_only_turn_is_continued(monkeypatch):
|
||||
"""DOKLADNIE zgloszony objaw: cala tura poszla na myslenie, tekst pusty.
|
||||
Wczesniej konczylo sie to pusta strona; teraz pytamy o tresc dalej."""
|
||||
seq = [
|
||||
{"content": [{"type": "thinking", "thinking": ""}], "stop_reason": "max_tokens",
|
||||
"usage": {"output_tokens": 2000}},
|
||||
{"content": [{"type": "text", "text": "Horoskop urodzeniowy..."}],
|
||||
"stop_reason": "end_turn", "usage": {"output_tokens": 500}},
|
||||
]
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
out = AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 40000)
|
||||
assert out.text == "Horoskop urodzeniowy..."
|
||||
assert out.usage["turns"] == 2
|
||||
|
||||
|
||||
def test_anthropic_sends_thinking_config(monkeypatch):
|
||||
"""Bez jawnego `thinking` Sonnet 5 wlacza myslenie sam — konfigurujemy to wprost."""
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"content": [{"type": "text", "text": "ok"}],
|
||||
"stop_reason": "end_turn"})
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
monkeypatch.delenv("ANTHROPIC_THINKING", raising=False)
|
||||
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
|
||||
assert seen[0]["thinking"] == {"type": "adaptive"}
|
||||
|
||||
seen.clear()
|
||||
monkeypatch.setenv("ANTHROPIC_THINKING", "off")
|
||||
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
|
||||
assert seen[0]["thinking"] == {"type": "disabled"}
|
||||
|
||||
|
||||
def test_complete_answer_does_not_loop(monkeypatch):
|
||||
"""Straznik nie moze mnozyc zapytan, gdy model skonczyl normalnie."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json=_chat("Gotowe.", "stop"))
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 40000)
|
||||
assert out.text == "Gotowe." and calls["n"] == 1
|
||||
|
||||
|
||||
# ------------------------------------------- wybor modelu przez uzytkownika (UI)
|
||||
|
||||
def test_model_from_request_wins_over_config(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
|
||||
p = factory.build_provider("anthropic", "claude-fable-5")
|
||||
assert p.model == "claude-fable-5", "wybor z UI musi wygrac nad konfiguracja"
|
||||
|
||||
|
||||
def test_blank_model_falls_back_to_configured_default(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-sonnet-5")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
|
||||
assert factory.build_provider("anthropic", " ").model == "claude-sonnet-5"
|
||||
|
||||
|
||||
def test_resolve_model_needs_no_api_key(monkeypatch):
|
||||
"""Budzet promptu zalezy od okna kontekstu modelu — nie moze wymagac klucza.
|
||||
|
||||
Wczesniej liczenie budzetu szlo przez build_provider(), ktory bez klucza
|
||||
rzuca bledem, wiec „maksymalny kontekst" cicho spadal do wartosci zapasowej.
|
||||
"""
|
||||
_clear(monkeypatch)
|
||||
provider, model = factory.resolve_model("anthropic", "claude-haiku-4-5")
|
||||
assert (provider, model) == ("anthropic", "claude-haiku-4-5")
|
||||
with pytest.raises(LLMError): # samo zbudowanie nadal wymaga klucza
|
||||
factory.build_provider("anthropic", "claude-haiku-4-5")
|
||||
|
||||
|
||||
def test_max_budget_differs_between_models(monkeypatch):
|
||||
"""Sedno funkcji: wieksze okno = wiekszy budzet promptu."""
|
||||
from app.llm.limits import prompt_token_budget
|
||||
_clear(monkeypatch)
|
||||
opus = prompt_token_budget(*factory.resolve_model("anthropic", "claude-opus-4-8"))
|
||||
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
||||
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
||||
assert opus > haiku > local > 0
|
||||
|
||||
|
||||
def test_turn_budget_counts_produced_not_requested(monkeypatch):
|
||||
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
|
||||
tokenow konczylo petle po jednej turze — urwany fragment wracal jako calosc."""
|
||||
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
|
||||
_chat("Zakonczenie. KONIEC", "stop")]
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
|
||||
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
|
||||
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
|
||||
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
|
||||
|
||||
|
||||
def test_generate_reports_progress_events(monkeypatch):
|
||||
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
|
||||
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(
|
||||
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
|
||||
events = []
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
|
||||
"p", 8000, on_event=events.append)
|
||||
kinds = [e["type"] for e in events]
|
||||
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
|
||||
assert kinds[-1] == "generated"
|
||||
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Generator promptów (LOG-29) i budżetowanie (LOG-30).
|
||||
|
||||
Testy nie wołają żadnego modelu — sprawdzają skład promptu i niezmienniki redukcji.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.prompt import (
|
||||
BUDGETS,
|
||||
build_natal_prompt,
|
||||
build_period_prompt,
|
||||
natal_indications,
|
||||
period_indications,
|
||||
reduce_indications,
|
||||
)
|
||||
|
||||
CHART = {
|
||||
"zodiac": "tropical",
|
||||
"house_system": "whole_sign",
|
||||
"sect": "day",
|
||||
"positions": [
|
||||
{"name": "Sun", "sign": "Taurus", "in_sign": "Tau 10°12'37\"", "house": 11, "direction": "D"},
|
||||
{"name": "Moon", "sign": "Aries", "in_sign": "Ari 7°48'40\"", "house": 10, "direction": "D"},
|
||||
],
|
||||
"angles": {"Asc": {"name": "Asc", "in_sign": "Can 16°00'54\""},
|
||||
"MC": {"name": "MC", "in_sign": "Pis 25°50'50\""}},
|
||||
"lots": [{"name": "Fortune", "in_sign": "Can 7°15'14\"", "house": 1,
|
||||
"formula": "Asc + Moon − Sun"}],
|
||||
"aspects": [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.34, "as": "A"}],
|
||||
}
|
||||
|
||||
|
||||
def _report(n_samples=3, score=2.0):
|
||||
return {"objects": [{
|
||||
"object": "Sun", "sign": "Taurus", "house": 11,
|
||||
"facets": [{
|
||||
"type": "sign", "label": "w znaku Taurus", "score": score,
|
||||
"samples": [{"significator": f"[Su in [Tau {i}", "expanded": f"Sun in Taurus {i}",
|
||||
"effect": f"efekt numer {i}"} for i in range(n_samples)],
|
||||
}],
|
||||
}]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- budżetowanie
|
||||
|
||||
def test_budget_limits_prompt_size():
|
||||
# opisy MUSZĄ być różne — identyczne zlałoby grupowanie w jedną pozycję
|
||||
big = {"objects": [{
|
||||
"object": "Sun", "sign": "Taurus", "house": 11,
|
||||
"facets": [{"type": "sign", "label": "w znaku Taurus", "score": 1.0,
|
||||
"samples": [{"significator": f"[Su {i}", "expanded": f"Sun {i}",
|
||||
"effect": f"opis {i} " + "x" * 200} for i in range(500)]}],
|
||||
}]}
|
||||
out = build_natal_prompt(CHART, big, budget="concise")
|
||||
assert out["stats"]["chars"] <= BUDGETS["concise"] * 1.1 # z marginesem na sekcje stałe
|
||||
assert out["stats"]["omitted"] > 0
|
||||
|
||||
|
||||
def test_bigger_budget_includes_more():
|
||||
big = _report(n_samples=300, score=1.0)
|
||||
small = build_natal_prompt(CHART, big, budget="concise")["stats"]
|
||||
large = build_natal_prompt(CHART, big, budget="extensive")["stats"]
|
||||
assert large["included"] > small["included"]
|
||||
assert large["omitted"] < small["omitted"]
|
||||
|
||||
|
||||
def test_grouping_merges_identical_effects():
|
||||
items = [
|
||||
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
|
||||
"significator": "Sun in Taurus", "effect": "ten sam opis"},
|
||||
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
|
||||
"significator": "Sun in Taurus (inny zapis)", "effect": "Ten Sam Opis "},
|
||||
]
|
||||
chosen, stats = reduce_indications(items, 10_000)
|
||||
assert len(chosen) == 1 # zlane w jedno
|
||||
assert chosen[0]["count"] == 2 # z licznikiem wystąpień
|
||||
assert stats["after_grouping"] == 1
|
||||
assert stats["deduplicated"] == 1
|
||||
|
||||
|
||||
def test_sorted_by_score_desc():
|
||||
items = [
|
||||
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
|
||||
"significator": f"s{i}", "effect": f"e{i}"} for i in range(5)
|
||||
]
|
||||
chosen, _ = reduce_indications(items, 10_000)
|
||||
scores = [c["score"] for c in chosen]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
|
||||
def test_weakest_are_dropped_first():
|
||||
items = [
|
||||
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
|
||||
"significator": f"s{i}", "effect": "opis " * 20} for i in range(20)
|
||||
]
|
||||
chosen, stats = reduce_indications(items, 400)
|
||||
assert stats["omitted"] > 0
|
||||
# to, co weszło, ma wagę nie niższą niż to, co odpadło
|
||||
assert stats["min_score_included"] >= stats["max_score_omitted"]
|
||||
|
||||
|
||||
def test_long_effects_are_shortened():
|
||||
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
|
||||
"significator": "s", "effect": "x" * 1000}]
|
||||
chosen, stats = reduce_indications(items, 10_000)
|
||||
assert stats["shortened"] == 1
|
||||
assert "skrócono" in chosen[0]["effect"]
|
||||
|
||||
|
||||
def test_never_truncates_mid_indication():
|
||||
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
|
||||
"significator": "s", "effect": "opis " * 50} for _ in range(10)]
|
||||
chosen, _ = reduce_indications(items, 200)
|
||||
assert chosen # zawsze co najmniej jedno całe
|
||||
for c in chosen:
|
||||
assert c["effect"].endswith(("opis", "[…skrócono]")) # nie urwane w pół słowa
|
||||
|
||||
|
||||
def test_unknown_budget_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
build_natal_prompt(CHART, _report(), budget="gigantyczny")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- skład promptu
|
||||
|
||||
def test_natal_prompt_contains_required_sections():
|
||||
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
|
||||
for section in ["# ZADANIE", "# DANE HOROSKOPU", "# WSKAZANIA Z BAZ",
|
||||
"# JAK MA WYGLĄDAĆ ODPOWIEDŹ", "# ZASTRZEŻENIE"]:
|
||||
assert section in p
|
||||
assert "HOROSKOPU\nURODZENIOWEGO" in p or "URODZENIOWEGO" in p
|
||||
|
||||
|
||||
def test_natal_prompt_carries_chart_data():
|
||||
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
|
||||
assert "Tau 10°12'37\"" in p and "dom 11" in p # pozycja + dom
|
||||
assert "Can 16°00'54\"" in p # Asc
|
||||
assert "Fortune" in p # Lots
|
||||
assert "Sun conjunction Moon" in p or "conjunction" in p
|
||||
assert "sekta: dzienna" in p
|
||||
assert "1984-04-30 09:20 UTC" in p
|
||||
|
||||
|
||||
def test_prompt_demands_citing_significators():
|
||||
p = build_natal_prompt(CHART, _report())["prompt"]
|
||||
assert "Teza bez wskazania jest niedopuszczalna" in p
|
||||
assert "Nie zmyślaj" in p
|
||||
|
||||
|
||||
def test_prompt_has_disclaimer():
|
||||
p = build_natal_prompt(CHART, _report())["prompt"]
|
||||
assert "nie stanowi porady" in p
|
||||
|
||||
|
||||
def test_prompt_is_deterministic():
|
||||
a = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
|
||||
b = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_omission_is_reported_in_prompt():
|
||||
out = build_natal_prompt(CHART, _report(n_samples=400, score=1.0), budget="concise")
|
||||
assert out["stats"]["omitted"] > 0
|
||||
assert "pominięto" in out["prompt"] # użytkownik/model wie, że coś odpadło
|
||||
|
||||
|
||||
def test_empty_report_still_builds_prompt():
|
||||
out = build_natal_prompt(CHART, {"objects": []})
|
||||
assert out["stats"]["included"] == 0
|
||||
assert "brak trafień" in out["prompt"]
|
||||
assert "# DANE HOROSKOPU" in out["prompt"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- okresowy
|
||||
|
||||
EVENTS = [
|
||||
{"technique": "profection", "significator": "Lord of Year: Mars", "start": "2026-04-30",
|
||||
"exact": "2026-04-30", "end": "2027-04-30", "interpretations_count": 2,
|
||||
"interpretations": [{"significator": "[Ma", "expanded": "Mars", "effect": "opis marsowy"}]},
|
||||
{"technique": "solar_arc", "significator": "Sun conj Saturn", "start": "2026-01-01",
|
||||
"exact": "2026-06-15", "end": "2026-12-31", "interpretations_count": 1,
|
||||
"interpretations": [{"significator": "[Su [conj [Sa", "expanded": "Sun conjunction Saturn",
|
||||
"effect": "opis saturniczny"}]},
|
||||
]
|
||||
|
||||
|
||||
def test_period_prompt_has_dates_and_timeline():
|
||||
out = build_period_prompt(CHART, EVENTS, "2026-01-01", "2027-01-01")
|
||||
p = out["prompt"]
|
||||
assert "2026-01-01 — 2027-01-01" in p
|
||||
assert "# OŚ CZASU" in p
|
||||
assert "profection" in p and "solar_arc" in p
|
||||
assert "2026-06-15" in p # data dokładna zdarzenia
|
||||
assert "CHRONOLOGICZNIE" in p
|
||||
assert out["stats"]["events"] == 2
|
||||
assert out["profile"] == "period"
|
||||
|
||||
|
||||
def test_period_prompt_keeps_timeline_without_interpretations():
|
||||
"""Padnięta warstwa danych zabiera wskazania, ale NIE oś czasu — ta jest czysto
|
||||
obliczeniowa i bez niej prognoza okresowa jest bezużyteczna."""
|
||||
bare = [{k: v for k, v in ev.items()
|
||||
if k not in ("interpretations", "interpretations_count")} for ev in EVENTS]
|
||||
out = build_period_prompt(CHART, bare, "2026-01-01", "2027-01-01")
|
||||
assert out["stats"]["events"] == 2
|
||||
assert "profection" in out["prompt"] and "2026-06-15" in out["prompt"]
|
||||
assert out["stats"]["included"] == 0 # brak wskazań, ale oś czasu jest
|
||||
|
||||
|
||||
def test_period_indications_weight_by_hit_count():
|
||||
items = period_indications(EVENTS)
|
||||
assert [i["score"] for i in items] == [2.0, 1.0]
|
||||
|
||||
|
||||
def test_natal_indications_flatten_facets():
|
||||
items = natal_indications(_report(n_samples=4))
|
||||
assert len(items) == 4
|
||||
assert all(i["context"].startswith("Sun —") for i in items)
|
||||
assert all(i["score"] == 2.0 for i in items)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Token międzywarstwowy (LOG-32).
|
||||
|
||||
Warstwa logiczna oddaje treść baz, więc musi odrzucać żądania z pominięciem UI.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app import security
|
||||
|
||||
TOKEN = "tajny-token-testowy"
|
||||
|
||||
|
||||
def _app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
security.install(app, "testowa")
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/secret")
|
||||
def secret():
|
||||
return {"rows": ["treść z bazy"]}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guarded(monkeypatch):
|
||||
monkeypatch.setenv("INTERNAL_TOKEN", TOKEN)
|
||||
return TestClient(_app())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_app(monkeypatch):
|
||||
monkeypatch.delenv("INTERNAL_TOKEN", raising=False)
|
||||
return TestClient(_app())
|
||||
|
||||
|
||||
def test_rejects_request_without_token(guarded):
|
||||
assert guarded.get("/secret").status_code == 401
|
||||
|
||||
|
||||
def test_rejects_wrong_token(guarded):
|
||||
r = guarded.get("/secret", headers={security.HEADER: "zly"})
|
||||
assert r.status_code == 401
|
||||
assert "treść z bazy" not in r.text
|
||||
|
||||
|
||||
def test_accepts_correct_token(guarded):
|
||||
r = guarded.get("/secret", headers={security.HEADER: TOKEN})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["rows"] == ["treść z bazy"]
|
||||
|
||||
|
||||
def test_health_stays_public(guarded):
|
||||
"""Sonda k8s nie zna tokenu — /health musi działać bez niego."""
|
||||
assert guarded.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_disabled_when_token_unset(open_app):
|
||||
"""Brak konfiguracji = zgodność wstecz (dev), nie blokada."""
|
||||
assert not security.enabled()
|
||||
assert open_app.get("/secret").status_code == 200
|
||||
@@ -5,6 +5,7 @@ opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -12,6 +13,12 @@ import httpx
|
||||
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 {}
|
||||
|
||||
|
||||
class LogicClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
||||
@@ -19,7 +26,7 @@ class LogicClient:
|
||||
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/api/query", json=payload)
|
||||
r = client.post(f"{self.base_url}/api/query", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -45,7 +52,7 @@ class LogicClient:
|
||||
}
|
||||
# stacje wymagają root-findów — dłuższy timeout
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
||||
r = client.post(f"{self.base_url}/chart/positions", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -55,7 +62,73 @@ class LogicClient:
|
||||
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||
r = client.post(f"{self.base_url}/chart/report", json=payload)
|
||||
r = client.post(f"{self.base_url}/chart/report", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def prompt(
|
||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
||||
provider: str | None = None, model: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
|
||||
|
||||
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
|
||||
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
|
||||
payload: dict[str, Any] = {
|
||||
"profile": profile, "when_utc": when_utc_iso,
|
||||
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
if from_date and to_date:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
||||
r = client.post(f"{self.base_url}/chart/prompt", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def horoscope(
|
||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", provider: str | None = None, model: str | None = None,
|
||||
from_date: str | None = None, to_date: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
|
||||
|
||||
Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"profile": profile, "when_utc": when_utc_iso,
|
||||
"lat": lat, "lon": lon, "budget": budget,
|
||||
}
|
||||
if provider:
|
||||
payload["provider"] = provider
|
||||
if model:
|
||||
payload["model"] = model
|
||||
if from_date and to_date:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
|
||||
r = client.post(f"{self.base_url}/chart/horoscope", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def horoscope_stream(self, payload: dict[str, Any]):
|
||||
"""Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
|
||||
|
||||
Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
|
||||
więc cisza na łączu nie zostanie wzięta za zerwanie.
|
||||
"""
|
||||
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
||||
with client.stream("POST", f"{self.base_url}/chart/horoscope/stream",
|
||||
json=payload, headers=_auth_headers()) as r:
|
||||
r.raise_for_status()
|
||||
for chunk in r.iter_lines():
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
def llm_models(self) -> dict[str, Any]:
|
||||
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.get(f"{self.base_url}/llm/models", headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -69,6 +142,6 @@ class LogicClient:
|
||||
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
||||
}
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
||||
r = client.post(f"{self.base_url}/chart/timeline", json=payload)
|
||||
r = client.post(f"{self.base_url}/chart/timeline", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -9,15 +9,16 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app import geocode
|
||||
from app import geocode, security
|
||||
from app.clients.logic_client import LogicClient
|
||||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||
|
||||
@@ -25,6 +26,7 @@ app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
logic = LogicClient()
|
||||
security.install(app) # logowanie + limit żądań (LOG-32)
|
||||
|
||||
|
||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
@@ -38,6 +40,15 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
return utc.isoformat(), label
|
||||
|
||||
|
||||
def _llm_catalog() -> dict:
|
||||
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
||||
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
||||
try:
|
||||
return logic.llm_models()
|
||||
except httpx.HTTPError:
|
||||
return {"providers": {}, "defaults": {}}
|
||||
|
||||
|
||||
def _logic_error(e: Exception) -> str:
|
||||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||||
return (
|
||||
@@ -52,7 +63,8 @@ def _logic_error(e: Exception) -> str:
|
||||
def chart_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "chart.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog()},
|
||||
)
|
||||
|
||||
|
||||
@@ -114,7 +126,8 @@ def significators_search(
|
||||
def interpret_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "interpret.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog()},
|
||||
)
|
||||
|
||||
|
||||
@@ -127,14 +140,31 @@ def interpret_run(
|
||||
lat: float = Form(0.0),
|
||||
lon: float = Form(0.0),
|
||||
group: bool = Form(False),
|
||||
action: str = Form("report"),
|
||||
prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form("local"),
|
||||
llm_model: str = Form(""),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "group": group}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||
"llm_catalog": _llm_catalog()}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||
if action == "prompt":
|
||||
ctx["prompt_result"] = logic.prompt(
|
||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
||||
provider=llm_provider, model=llm_model,
|
||||
)
|
||||
elif action == "horoscope":
|
||||
ctx["prompt_result"] = logic.horoscope(
|
||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||
)
|
||||
else:
|
||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||
except httpx.HTTPError as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
except ValueError as e:
|
||||
@@ -147,7 +177,8 @@ def interpret_run(
|
||||
def timeline_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "timeline.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
"llm_catalog": _llm_catalog()},
|
||||
)
|
||||
|
||||
|
||||
@@ -161,17 +192,36 @@ def timeline_run(
|
||||
lon: float = Form(0.0),
|
||||
from_date: str = Form(...),
|
||||
to_date: str = Form(...),
|
||||
action: str = Form("timeline"),
|
||||
prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form("local"),
|
||||
llm_model: str = Form(""),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||
"from_date": from_date, "to_date": to_date}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||
"llm_catalog": _llm_catalog()}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.timeline(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
from_date=from_date, to_date=to_date, interpret=True,
|
||||
)
|
||||
if action == "prompt":
|
||||
ctx["prompt_result"] = logic.prompt(
|
||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||||
provider=llm_provider, model=llm_model,
|
||||
)
|
||||
elif action == "horoscope":
|
||||
ctx["prompt_result"] = logic.horoscope(
|
||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||
from_date=from_date, to_date=to_date,
|
||||
)
|
||||
else:
|
||||
ctx["result"] = logic.timeline(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
from_date=from_date, to_date=to_date, interpret=True,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
except ValueError as e:
|
||||
@@ -179,6 +229,61 @@ def timeline_run(
|
||||
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||||
|
||||
|
||||
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
|
||||
@app.post("/horoscope/stream")
|
||||
def horoscope_stream(
|
||||
profile: str = Form("natal"),
|
||||
date: str = Form(...),
|
||||
time: str = Form(...),
|
||||
tz_offset: float = Form(0.0),
|
||||
lat: float = Form(0.0),
|
||||
lon: float = Form(0.0),
|
||||
prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form("local"),
|
||||
llm_model: str = Form(""),
|
||||
from_date: str = Form(""),
|
||||
to_date: str = Form(""),
|
||||
):
|
||||
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
|
||||
|
||||
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
|
||||
przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku.
|
||||
"""
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
try:
|
||||
iso_utc, _ = _build_utc(date, time, tz_offset)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||||
|
||||
payload: dict = {
|
||||
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
|
||||
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
|
||||
}
|
||||
if profile == "period" and from_date and to_date:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
|
||||
def relay():
|
||||
try:
|
||||
for raw in logic.horoscope_stream(payload):
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if event.get("type") == "result":
|
||||
html = templates.get_template("_prompt_result.html").render(
|
||||
prompt_result=event.get("result") or {}
|
||||
)
|
||||
event["html"] = html
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except httpx.HTTPError as e:
|
||||
yield json.dumps({"type": "error", "message": _logic_error(e)},
|
||||
ensure_ascii=False) + "\n"
|
||||
|
||||
return StreamingResponse(relay(), media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
|
||||
|
||||
|
||||
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||||
@app.get("/geocode")
|
||||
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Ochrona dostępu do aplikacji (LOG-32).
|
||||
|
||||
Rdzeniem produktu są oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
|
||||
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
|
||||
uwierzytelnienia oznacza, że każdy w sieci może je wypompować — bez udziału
|
||||
jakiegokolwiek modelu językowego. Ten moduł zamyka tę drogę.
|
||||
|
||||
Dwa mechanizmy:
|
||||
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
|
||||
* **limit żądań** — hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań).
|
||||
|
||||
Świadomie NIE logujemy treści żądań ani promptów — logi to kolejny nośnik wycieku.
|
||||
|
||||
UWAGA: bez APP_PASSWORD ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev).
|
||||
Wtedy przy starcie leci głośne ostrzeżenie — żeby nikt nie wdrożył tego w
|
||||
przekonaniu, że jest chroniony.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
log = logging.getLogger("astrololo.security")
|
||||
|
||||
MAX_TRACKED_CLIENTS_DEFAULT = 4096
|
||||
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
|
||||
|
||||
|
||||
# konfiguracja czytana leniwie — testy i restart mogą ją zmienić bez importu modułu
|
||||
def app_user() -> str:
|
||||
return os.getenv("APP_USER", "astrololo")
|
||||
|
||||
|
||||
def app_password() -> str:
|
||||
return os.getenv("APP_PASSWORD", "")
|
||||
|
||||
|
||||
def rate_limit_per_min() -> int:
|
||||
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
|
||||
|
||||
PUBLIC_PATHS = frozenset({"/health"})
|
||||
PUBLIC_PREFIXES = ("/static/",)
|
||||
|
||||
_hits: dict[str, deque[float]] = {}
|
||||
|
||||
|
||||
def auth_enabled() -> bool:
|
||||
return bool(app_password())
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
||||
|
||||
|
||||
def _authorized(header: str | None) -> bool:
|
||||
if not header or not header.lower().startswith("basic "):
|
||||
return False
|
||||
try:
|
||||
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
||||
user, _, password = raw.partition(":")
|
||||
except (binascii.Error, UnicodeDecodeError, IndexError):
|
||||
return False
|
||||
# porównanie odporne na atak czasowy; oba pola muszą się zgadzać
|
||||
ok_user = secrets.compare_digest(user, app_user())
|
||||
ok_pass = secrets.compare_digest(password, app_password())
|
||||
return ok_user and ok_pass
|
||||
|
||||
|
||||
def _rate_limited(client: str) -> bool:
|
||||
cap = rate_limit_per_min()
|
||||
if cap <= 0:
|
||||
return False
|
||||
now = time.monotonic()
|
||||
window = _hits.get(client)
|
||||
if window is None:
|
||||
if len(_hits) >= MAX_TRACKED_CLIENTS:
|
||||
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
|
||||
window = _hits[client] = deque()
|
||||
while window and now - window[0] > 60.0:
|
||||
window.popleft()
|
||||
if len(window) >= cap:
|
||||
return True
|
||||
window.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def install(app) -> None:
|
||||
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
|
||||
if not auth_enabled():
|
||||
log.warning(
|
||||
"UWAGA: APP_PASSWORD nie ustawione — aplikacja jest OTWARTA dla każdego, "
|
||||
"kto ma do niej dostęp sieciowy, wraz z treścią baz interpretacyjnych."
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def _guard(request: Request, call_next):
|
||||
if _is_public(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
client = request.client.host if request.client else "?"
|
||||
if _rate_limited(client):
|
||||
return JSONResponse(
|
||||
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
||||
status_code=429, headers={"Retry-After": "60"},
|
||||
)
|
||||
|
||||
if auth_enabled() and not _authorized(request.headers.get("authorization")):
|
||||
return HTMLResponse(
|
||||
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
||||
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
|
||||
)
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,31 @@
|
||||
// Kopiowanie do schowka dla przycisków [data-copy="#selektor"].
|
||||
// clipboard API wymaga secure context (https/localhost), a aplikacja bywa serwowana
|
||||
// po http://<ip> — dlatego jest awaryjne przejście na zaznaczenie + execCommand.
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('[data-copy]');
|
||||
if (!btn) return;
|
||||
|
||||
const src = document.querySelector(btn.getAttribute('data-copy'));
|
||||
if (!src) return;
|
||||
|
||||
const done = ok => {
|
||||
const label = btn.textContent;
|
||||
btn.textContent = ok ? 'Skopiowano ✓' : 'Nie udało się — zaznacz i skopiuj ręcznie';
|
||||
setTimeout(() => { btn.textContent = label; }, 2500);
|
||||
};
|
||||
|
||||
const text = src.value !== undefined ? src.value : src.textContent;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => done(true), () => done(false));
|
||||
return;
|
||||
}
|
||||
// awaryjnie: zaznacz zawartość i spróbuj starym poleceniem
|
||||
try {
|
||||
src.focus();
|
||||
if (src.select) src.select();
|
||||
done(document.execCommand('copy'));
|
||||
} catch (err) {
|
||||
done(false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Podpowiedzi modeli zależne od wybranego dostawcy.
|
||||
//
|
||||
// Pole modelu jest CELOWO tekstowe (input + datalist), a nie zamkniętym <select>:
|
||||
// katalog to tylko wygoda, a konto może mieć dostęp do modeli, o których kod nie
|
||||
// wie. Puste pole = model domyślny dostawcy.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const providerEl = document.getElementById('llmProvider');
|
||||
const modelEl = document.getElementById('llmModel');
|
||||
const listEl = document.getElementById('llmModelList');
|
||||
const hintEl = document.getElementById('llmModelHint');
|
||||
const dataEl = document.getElementById('llmCatalog');
|
||||
if (!providerEl || !modelEl || !listEl || !dataEl) return;
|
||||
|
||||
let catalog = {};
|
||||
let defaults = {};
|
||||
try {
|
||||
const parsed = JSON.parse(dataEl.textContent || '{}');
|
||||
catalog = parsed.providers || {};
|
||||
defaults = parsed.defaults || {};
|
||||
} catch (e) {
|
||||
return; // brak katalogu — pole nadal działa jako wolny tekst
|
||||
}
|
||||
|
||||
const fmt = n =>
|
||||
n >= 1000000 ? (n / 1000000) + 'M' : (n >= 1000 ? Math.round(n / 1000) + 'k' : String(n));
|
||||
|
||||
function refresh(resetValue) {
|
||||
const provider = providerEl.value;
|
||||
const models = catalog[provider] || [];
|
||||
|
||||
listEl.innerHTML = '';
|
||||
models.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.label = m.label + ' · kontekst ' + fmt(m.context_window);
|
||||
listEl.appendChild(opt);
|
||||
});
|
||||
|
||||
const fallback = defaults[provider] || '';
|
||||
modelEl.placeholder = fallback ? 'domyślny: ' + fallback : 'domyślny dostawcy';
|
||||
|
||||
// po zmianie dostawcy stary model nie ma sensu (np. gpt-4o u Anthropica)
|
||||
if (resetValue) modelEl.value = '';
|
||||
|
||||
if (hintEl) {
|
||||
const chosen = models.find(m => m.id === modelEl.value.trim());
|
||||
hintEl.textContent = chosen
|
||||
? chosen.label + ' — okno kontekstu ' + fmt(chosen.context_window) +
|
||||
' tokenów, maksymalna odpowiedź ' + fmt(chosen.max_output) + '.'
|
||||
: 'Zostaw puste, by użyć modelu domyślnego. Możesz też wpisać dowolny ' +
|
||||
'identyfikator modelu, do którego Twoje konto ma dostęp.';
|
||||
}
|
||||
}
|
||||
|
||||
providerEl.addEventListener('change', () => refresh(true));
|
||||
modelEl.addEventListener('input', () => refresh(false));
|
||||
refresh(false);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// Okno postępu przy pisaniu horoskopu.
|
||||
//
|
||||
// Problem: generowanie trwa minutami, a zwykły POST formularza nie daje żadnego
|
||||
// sygnału — aplikacja wygląda na zawieszoną. Zamiast udawanego paska postępu
|
||||
// czytamy strumień RZECZYWISTYCH zdarzeń z serwera (NDJSON) i wypisujemy je
|
||||
// jako log: budowa promptu, limity modelu, każda tura generowania.
|
||||
//
|
||||
// Degradacja: jeśli przeglądarka nie umie strumieniować `fetch`, nie przechwytujemy
|
||||
// wysyłki — formularz idzie klasycznie i wszystko działa jak wcześniej, tylko bez okna.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const canStream = typeof fetch === 'function' && typeof ReadableStream === 'function' &&
|
||||
typeof TextDecoder === 'function';
|
||||
const form = document.querySelector('form[action="/interpret"], form[action="/timeline"]');
|
||||
if (!canStream || !form) return;
|
||||
|
||||
const profile = form.getAttribute('action') === '/timeline' ? 'period' : 'natal';
|
||||
const btn = form.querySelector('button[value="horoscope"]');
|
||||
if (!btn) return;
|
||||
|
||||
// --- okno ---------------------------------------------------------------
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'progress-overlay';
|
||||
overlay.hidden = true;
|
||||
overlay.innerHTML =
|
||||
'<div class="progress-box" role="dialog" aria-modal="true" aria-label="Postęp generowania">' +
|
||||
'<div class="progress-head">' +
|
||||
'<span class="progress-spinner" aria-hidden="true"></span>' +
|
||||
'<strong id="progressTitle">Piszę horoskop…</strong>' +
|
||||
'<span class="progress-clock" id="progressClock">0:00</span>' +
|
||||
'</div>' +
|
||||
'<ol class="progress-log" id="progressLog"></ol>' +
|
||||
'<p class="muted small">Nie zamykaj tej karty — generowanie trwa na serwerze.</p>' +
|
||||
'<div class="actions"><button type="button" class="ghost" id="progressClose" hidden>Zamknij</button></div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const logEl = overlay.querySelector('#progressLog');
|
||||
const clockEl = overlay.querySelector('#progressClock');
|
||||
const titleEl = overlay.querySelector('#progressTitle');
|
||||
const closeEl = overlay.querySelector('#progressClose');
|
||||
let timer = null;
|
||||
|
||||
function addLine(text, kind) {
|
||||
const li = document.createElement('li');
|
||||
if (kind) li.className = 'log-' + kind;
|
||||
const now = new Date();
|
||||
li.textContent = String(now.getHours()).padStart(2, '0') + ':' +
|
||||
String(now.getMinutes()).padStart(2, '0') + ':' +
|
||||
String(now.getSeconds()).padStart(2, '0') + ' ' + text;
|
||||
logEl.appendChild(li);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function startClock() {
|
||||
const t0 = Date.now();
|
||||
timer = setInterval(function () {
|
||||
const s = Math.floor((Date.now() - t0) / 1000);
|
||||
clockEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function finish(title, allowClose) {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
titleEl.textContent = title;
|
||||
overlay.querySelector('.progress-spinner').style.visibility = 'hidden';
|
||||
if (allowClose) closeEl.hidden = false;
|
||||
}
|
||||
|
||||
closeEl.addEventListener('click', function () { overlay.hidden = true; });
|
||||
|
||||
// --- przechwycenie wysyłki ----------------------------------------------
|
||||
btn.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.reportValidity()) return; // te same reguły co przy zwykłej wysyłce
|
||||
|
||||
const data = new FormData(form);
|
||||
data.set('profile', profile);
|
||||
|
||||
logEl.innerHTML = '';
|
||||
closeEl.hidden = true;
|
||||
overlay.querySelector('.progress-spinner').style.visibility = '';
|
||||
titleEl.textContent = 'Piszę horoskop…';
|
||||
overlay.hidden = false;
|
||||
startClock();
|
||||
addLine('Wysyłam żądanie…');
|
||||
|
||||
fetch('/horoscope/stream', { method: 'POST', body: data })
|
||||
.then(function (response) {
|
||||
if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
function pump() {
|
||||
return reader.read().then(function (chunk) {
|
||||
if (chunk.done) return;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop(); // ostatni może być niepełny
|
||||
lines.forEach(function (raw) {
|
||||
if (!raw.trim()) return;
|
||||
let ev;
|
||||
try { ev = JSON.parse(raw); } catch (e) { return; }
|
||||
if (ev.type === 'ping') return; // sam heartbeat, nie logujemy
|
||||
if (ev.type === 'result') {
|
||||
addLine(ev.message || 'Gotowe.', 'ok');
|
||||
if (ev.html) {
|
||||
const host = document.getElementById('promptResult');
|
||||
if (host) host.innerHTML = ev.html;
|
||||
}
|
||||
finish('Gotowe', true);
|
||||
overlay.hidden = true;
|
||||
const host = document.getElementById('promptResult');
|
||||
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
|
||||
: ev.type === 'warn' ? 'warn' : null);
|
||||
if (ev.type === 'error') finish('Nie udało się', true);
|
||||
});
|
||||
return pump();
|
||||
});
|
||||
}
|
||||
return pump();
|
||||
})
|
||||
.catch(function (e) {
|
||||
addLine('Połączenie przerwane: ' + e.message, 'err');
|
||||
addLine('Możesz spróbować ponownie — nic nie zostało utracone.');
|
||||
finish('Nie udało się', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,3 +81,34 @@ tr:last-child td { border-bottom: none; }
|
||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||
.leaflet-control-attribution { font-size: .68rem; }
|
||||
|
||||
/* Generator promptu do LLM (LOG-29/30) */
|
||||
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
/* Okno postępu przy generowaniu horoskopu */
|
||||
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 50; padding: 1rem; }
|
||||
.progress-overlay[hidden] { display: none; }
|
||||
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
||||
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
||||
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
||||
color: var(--muted); font-size: .9rem; }
|
||||
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
||||
border: 2px solid var(--line); border-top-color: var(--accent);
|
||||
animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
||||
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
||||
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
||||
max-height: 15rem; overflow-y: auto;
|
||||
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
||||
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
||||
.progress-log li.log-ok { color: #7fd18b; }
|
||||
.progress-log li.log-warn { color: #e0c060; }
|
||||
.progress-log li.log-err { color: #ef6b6b; }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{# Katalog modeli wstrzykiwany przez handler — pole jest tekstowe, więc
|
||||
można wpisać dowolny model, do którego konto ma dostęp. #}
|
||||
<script type="application/json" id="llmCatalog">{{ llm_catalog | tojson }}</script>
|
||||
{# Wspólny blok: sterowanie generowaniem + prompt (LOG-29/30) + horoskop (LOG-31).
|
||||
Używany na ekranach Interpretacje i Kalendarz. #}
|
||||
<div class="opts">
|
||||
<label>Budżet promptu
|
||||
<select name="prompt_budget">
|
||||
{% set pb = form.prompt_budget or 'medium' %}
|
||||
<option value="concise" {{ 'selected' if pb == 'concise' else '' }}>zwięzły (~4 tys. znaków)</option>
|
||||
<option value="medium" {{ 'selected' if pb == 'medium' else '' }}>średni (~12 tys.)</option>
|
||||
<option value="extensive" {{ 'selected' if pb == 'extensive' else '' }}>obszerny (~30 tys.)</option>
|
||||
<option value="huge" {{ 'selected' if pb == 'huge' else '' }}>bardzo obszerny (~120 tys.)</option>
|
||||
<option value="max" {{ 'selected' if pb == 'max' else '' }}>maksymalny kontekst modelu</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Dostawca
|
||||
<select name="llm_provider" id="llmProvider">
|
||||
{% set lp = form.llm_provider or 'local' %}
|
||||
<option value="local" {{ 'selected' if lp == 'local' else '' }}>lokalny — nic nie opuszcza sieci</option>
|
||||
<option value="anthropic" {{ 'selected' if lp == 'anthropic' else '' }}>Anthropic — dane wychodzą</option>
|
||||
<option value="openai" {{ 'selected' if lp == 'openai' else '' }}>OpenAI — dane wychodzą</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Model
|
||||
<input type="text" name="llm_model" id="llmModel" list="llmModelList"
|
||||
value="{{ form.llm_model or '' }}" placeholder="domyślny dostawcy"
|
||||
autocomplete="off">
|
||||
<datalist id="llmModelList"></datalist>
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted small" id="llmModelHint"></p>
|
||||
<p class="muted small">
|
||||
Prompt zawiera <strong>oryginalne opisy z baz</strong> oraz dane urodzeniowe. Model lokalny
|
||||
przetwarza je u nas; wybór dostawcy w chmurze oznacza, że ta treść <strong>opuszcza naszą
|
||||
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
||||
</p>
|
||||
|
||||
<div id="promptResult">{% include "_prompt_result.html" %}</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
{# Blok WYNIKU generowania (prompt/horoskop). Wydzielony, bo wstawia go także
|
||||
okno postępu po zakończeniu strumienia — dzięki temu jest jedno źródło
|
||||
prawdy dla wyglądu wyniku, niezależnie od drogi, którą przyszedł. #}
|
||||
{% if prompt_result %}
|
||||
{% set st = prompt_result.stats %}
|
||||
|
||||
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
|
||||
{% if prompt_result.horoscope %}
|
||||
<div class="meta">
|
||||
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
|
||||
model: {{ prompt_result.model }}
|
||||
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
|
||||
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
|
||||
{% endif %}
|
||||
{% if prompt_result.leaves_lan %}
|
||||
· <strong class="retro">dane opuściły sieć</strong>
|
||||
{% else %}
|
||||
· dane nie opuściły sieci
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
|
||||
</div>
|
||||
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
|
||||
<p class="muted small">
|
||||
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
|
||||
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if prompt_result.warnings %}
|
||||
{% for w in prompt_result.warnings %}
|
||||
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if prompt_result.token_plan %}
|
||||
{% set tp = prompt_result.token_plan %}
|
||||
<p class="muted small">
|
||||
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
|
||||
zarezerwowane na odpowiedź {{ tp.max_output }}
|
||||
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
|
||||
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if prompt_result.llm_error %}
|
||||
<div class="error">
|
||||
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
||||
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
|
||||
<div class="meta">
|
||||
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
|
||||
budżet: {{ st.budget }} ·
|
||||
wskazań: <strong>{{ st.included }}</strong>
|
||||
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
|
||||
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if st.omitted %}
|
||||
<p class="muted small">
|
||||
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
|
||||
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if prompt_result.data_error %}
|
||||
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
|
||||
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
|
||||
</div>
|
||||
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
|
||||
{% endif %}
|
||||
@@ -28,9 +28,12 @@
|
||||
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
||||
</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 %}
|
||||
{% include "_prompt_block.html" %}
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Szukaj interpretacji</button>
|
||||
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
@@ -86,4 +89,7 @@
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/now.js"></script>
|
||||
<script src="/static/copy.js"></script>
|
||||
<script src="/static/models.js"></script>
|
||||
<script src="/static/progress.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -33,9 +33,12 @@
|
||||
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
||||
</label>
|
||||
</div>
|
||||
{% include "_prompt_block.html" %}
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Pokaż kalendarz</button>
|
||||
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
@@ -76,4 +79,7 @@
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/now.js"></script>
|
||||
<script src="/static/copy.js"></script>
|
||||
<script src="/static/models.js"></script>
|
||||
<script src="/static/progress.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32).
|
||||
|
||||
Powód istnienia tego testu: token dodano do klienta na gałęzi, która odbiła się od
|
||||
mastera zanim powstały metody `prompt()` i `horoscope()`. Git zmergował obie zmiany
|
||||
czysto (różne linie), ale nowe metody wyszły BEZ tokenu — i dostawały 401 dopiero na
|
||||
produkcji. Zwykły test jednej metody by tego nie złapał, więc sprawdzamy regułę
|
||||
strukturalnie: nie ma wywołania bez `headers=`.
|
||||
"""
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
|
||||
|
||||
|
||||
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool]]:
|
||||
"""(nazwa_metody_http, linia, czy_ma_headers) dla każdego client.post/get."""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
out = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
||||
continue
|
||||
if node.func.attr not in ("post", "get", "put", "patch", "delete"):
|
||||
continue
|
||||
if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "client"):
|
||||
continue
|
||||
has_headers = any(kw.arg == "headers" for kw in node.keywords)
|
||||
out.append((node.func.attr, node.lineno, has_headers))
|
||||
return out
|
||||
|
||||
|
||||
def test_client_module_exists():
|
||||
assert CLIENT.is_file()
|
||||
|
||||
|
||||
def test_every_outbound_call_sends_auth_header():
|
||||
calls = _http_calls(CLIENT)
|
||||
assert calls, "nie znaleziono żadnego wywołania HTTP — test przestał cokolwiek pilnować"
|
||||
missing = [f"{CLIENT.name}:{line} client.{verb}()" for verb, line, ok in calls if not ok]
|
||||
assert not missing, (
|
||||
"Wywołania w dół bez tokenu międzywarstwowego (dostaną 401 przy włączonej "
|
||||
"ochronie): " + ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def test_auth_headers_helper_is_lazy():
|
||||
"""Token czytany przy wywołaniu, nie przy imporcie — inaczej pod wystartowałby
|
||||
z pustym tokenem, gdyby zmienna pojawiła się później."""
|
||||
import os
|
||||
|
||||
from app.clients.logic_client import _auth_headers
|
||||
|
||||
old = os.environ.get("INTERNAL_TOKEN")
|
||||
try:
|
||||
os.environ["INTERNAL_TOKEN"] = "abc"
|
||||
assert _auth_headers() == {"X-Astrololo-Token": "abc"}
|
||||
os.environ.pop("INTERNAL_TOKEN")
|
||||
assert _auth_headers() == {} # ochrona wyłączona = brak nagłówka
|
||||
finally:
|
||||
if old is not None:
|
||||
os.environ["INTERNAL_TOKEN"] = old
|
||||
else:
|
||||
os.environ.pop("INTERNAL_TOKEN", None)
|
||||
|
||||
|
||||
# --------------------------------------------- wybor modelu musi dojsc do logiki
|
||||
# Ta sama klasa bledu co przy tokenie: dokladajac nowa sciezke latwo zapomniec
|
||||
# przekazac parametr, a objaw (cichy powrot do modelu domyslnego) jest niewidoczny.
|
||||
|
||||
def _calls_to(path_fragment: str) -> list[int]:
|
||||
"""Linie wywolan logic.<metoda>(...) w handlerach prezentacji."""
|
||||
main = CLIENT.parent.parent / "main.py"
|
||||
tree = ast.parse(main.read_text(encoding="utf-8"))
|
||||
out = []
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == path_fragment
|
||||
and isinstance(node.func.value, ast.Name) and node.func.value.id == "logic"):
|
||||
out.append(node.lineno)
|
||||
return out
|
||||
|
||||
|
||||
def _has_kwarg(main_src: str, lineno: int, name: str) -> bool:
|
||||
tree = ast.parse(main_src)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and node.lineno == lineno:
|
||||
return any(kw.arg == name for kw in node.keywords)
|
||||
return False
|
||||
|
||||
|
||||
def test_every_llm_call_passes_selected_model():
|
||||
main = CLIENT.parent.parent / "main.py"
|
||||
src = main.read_text(encoding="utf-8")
|
||||
missing = []
|
||||
for method in ("prompt", "horoscope"):
|
||||
for line in _calls_to(method):
|
||||
if not _has_kwarg(src, line, "model"):
|
||||
missing.append(f"main.py:{line} logic.{method}()")
|
||||
assert not missing, (
|
||||
"Wywołania bez wybranego modelu — po cichu użyją domyślnego: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def test_every_llm_call_passes_provider():
|
||||
main = CLIENT.parent.parent / "main.py"
|
||||
src = main.read_text(encoding="utf-8")
|
||||
missing = []
|
||||
for method in ("prompt", "horoscope"):
|
||||
for line in _calls_to(method):
|
||||
if not _has_kwarg(src, line, "provider"):
|
||||
missing.append(f"main.py:{line} logic.{method}()")
|
||||
assert not missing, "Wywołania bez dostawcy: " + ", ".join(missing)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Logowanie do aplikacji i limit żądań (LOG-32).
|
||||
|
||||
To jest brama chroniąca oryginalne bazy interpretacyjne — bez niej każdy w sieci
|
||||
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
|
||||
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
|
||||
"""
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app import security
|
||||
|
||||
USER, PASSWORD = "astrololo", "haslo-testowe"
|
||||
|
||||
|
||||
def _basic(user: str, password: str) -> dict[str, str]:
|
||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {raw}"}
|
||||
|
||||
|
||||
def _app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
security.install(app)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/significators", response_class=HTMLResponse)
|
||||
def significators():
|
||||
return "<p>treść z bazy interpretacyjnej</p>"
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limit():
|
||||
security._hits.clear()
|
||||
yield
|
||||
security._hits.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guarded(monkeypatch):
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
|
||||
return TestClient(_app())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ logowanie
|
||||
|
||||
def test_blocks_anonymous_access(guarded):
|
||||
r = guarded.get("/significators")
|
||||
assert r.status_code == 401
|
||||
assert "treść z bazy" not in r.text
|
||||
|
||||
|
||||
def test_challenges_with_basic_realm(guarded):
|
||||
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania."""
|
||||
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "")
|
||||
|
||||
|
||||
def test_rejects_wrong_password(guarded):
|
||||
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401
|
||||
|
||||
|
||||
def test_rejects_wrong_user(guarded):
|
||||
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401
|
||||
|
||||
|
||||
def test_rejects_malformed_header(guarded):
|
||||
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"):
|
||||
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
|
||||
|
||||
|
||||
def test_allows_correct_credentials(guarded):
|
||||
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
|
||||
assert r.status_code == 200
|
||||
assert "treść z bazy" in r.text
|
||||
|
||||
|
||||
def test_health_stays_public(guarded):
|
||||
assert guarded.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_open_when_password_unset(monkeypatch):
|
||||
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie)."""
|
||||
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||
assert not security.auth_enabled()
|
||||
assert TestClient(_app()).get("/significators").status_code == 200
|
||||
|
||||
|
||||
# --------------------------------------------------------------- limit żądań
|
||||
|
||||
def test_rate_limit_blocks_flood(monkeypatch):
|
||||
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
|
||||
client = TestClient(_app())
|
||||
auth = _basic(USER, PASSWORD)
|
||||
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
|
||||
assert codes[:5] == [200] * 5
|
||||
assert 429 in codes[5:]
|
||||
|
||||
|
||||
def test_rate_limited_response_has_retry_after(monkeypatch):
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
|
||||
client = TestClient(_app())
|
||||
auth = _basic(USER, PASSWORD)
|
||||
client.get("/significators", headers=auth)
|
||||
r = client.get("/significators", headers=auth)
|
||||
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
|
||||
|
||||
|
||||
def test_rate_limit_precedes_auth(monkeypatch):
|
||||
"""Limit musi działać także dla niezalogowanych — inaczej zgadywanie hasła
|
||||
i sondowanie API jest darmowe."""
|
||||
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
||||
client = TestClient(_app())
|
||||
codes = [client.get("/significators").status_code for _ in range(6)]
|
||||
assert 429 in codes
|
||||
|
||||
|
||||
def test_rate_limit_disabled_when_zero(monkeypatch):
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||
client = TestClient(_app())
|
||||
assert all(client.get("/significators").status_code == 200 for _ in range(30))
|
||||
Reference in New Issue
Block a user