diff --git a/services/logic/app/llm/factory.py b/services/logic/app/llm/factory.py
index 16620a6..afad1c4 100644
--- a/services/logic/app/llm/factory.py
+++ b/services/logic/app/llm/factory.py
@@ -40,7 +40,11 @@ PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
_DEFAULT_MODEL = {
LOCAL: "llama3.1:8b",
OPENAI: "gpt-4o-mini",
- ANTHROPIC: "claude-sonnet-5",
+ # 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
diff --git a/services/logic/app/llm/limits.py b/services/logic/app/llm/limits.py
new file mode 100644
index 0000000..3e81fa3
--- /dev/null
+++ b/services/logic/app/llm/limits.py
@@ -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
+(`_CONTEXT_WINDOW`, `_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)
diff --git a/services/logic/app/llm/providers.py b/services/logic/app/llm/providers.py
index d3a0c6b..1eb5f6a 100644
--- a/services/logic/app/llm/providers.py
+++ b/services/logic/app/llm/providers.py
@@ -5,9 +5,24 @@ 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
@@ -17,6 +32,23 @@ 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."""
@@ -37,8 +69,8 @@ def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) ->
time.sleep(2 ** attempt)
continue
raise LLMError(
- f"Model nie odpowiedział w czasie {timeout:.0f}s. Dłuższe horoskopy "
- f"wymagają większego LLM_TIMEOUT albo mniejszego budżetu promptu."
+ 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
@@ -49,40 +81,108 @@ def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) ->
raise LLMError(f"Nie udało się wywołać modelu: {last}")
-def _require_text(text: str, usage: dict, finish_reason: str | None, max_tokens: int) -> str:
- """Pusta odpowiedź modelu MUSI być błędem, nie pustym tekstem.
+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
- Inaczej mamy cichą awarię: warstwa wyżej dostaje `horoscope=""`, widok nic nie
- renderuje i NIC nie tłumaczy użytkownikowi, dlaczego strona wróciła pusta.
- Najczęstsza przyczyna: prompt wypełnił okno kontekstu modelu, więc na odpowiedź
- nie zostało miejsca (`finish_reason=length`, `completion_tokens=0`) — dotyczy to
- zwłaszcza obszernych promptów natalnych na modelach lokalnych o małym kontekście.
- """
- if text and text.strip():
- return text
- powod = []
- if finish_reason:
- powod.append(f"finish_reason={finish_reason}")
- if usage:
- pt, ct = usage.get("prompt_tokens"), usage.get("completion_tokens")
- if pt is not None:
- powod.append(f"tokeny promptu={pt}")
- if ct is not None:
- powod.append(f"tokeny odpowiedzi={ct}")
- szczegoly = f" ({', '.join(powod)})" if powod else ""
+def _join(parts: list[str]) -> str:
+ return "".join(parts).strip()
- rada = (
- "Najczęstsza przyczyna: prompt nie zmieścił się w oknie kontekstu modelu i na "
- "odpowiedź nie zostało miejsca. Zmniejsz budżet promptu (zwięzły), zwiększ okno "
- "kontekstu modelu (w Ollamie num_ctx) albo podnieś LLM_MAX_TOKENS."
- if finish_reason == "length" or (usage or {}).get("completion_tokens") == 0
- else "Model przyjął żądanie, ale nie wygenerował treści."
+
+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."
)
- raise LLMError(f"Model zwrócił pustą odpowiedź{szczegoly}. {rada}")
-class ChatCompletionsProvider(LLMProvider):
+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) -> Completion:
+ 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))
+ text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
+ _merge_usage(usage, turn_usage)
+ remaining -= budget
+
+ 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))
+
+ 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 = "",
@@ -100,27 +200,20 @@ class ChatCompletionsProvider(LLMProvider):
h["Authorization"] = f"Bearer {self.api_key}"
return h
- def generate(self, prompt: str, max_tokens: int) -> Completion:
+ 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": [{"role": "user", "content": prompt}],
- },
+ {"model": self.model, "max_tokens": max_tokens, "messages": messages},
self.timeout,
)
try:
choice = data["choices"][0]
- text = choice["message"]["content"]
+ 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
- usage = data.get("usage") or {}
- text = _require_text(text, usage, choice.get("finish_reason"), max_tokens)
- return Completion(
- text=text, model=data.get("model", self.model), provider=self.name,
- leaves_lan=self.leaves_lan, usage=usage,
- )
+ 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}
@@ -133,7 +226,7 @@ class ChatCompletionsProvider(LLMProvider):
return info
-class AnthropicProvider(LLMProvider):
+class AnthropicProvider(_Driver, LLMProvider):
"""Protokół Anthropic `/v1/messages`."""
leaves_lan = True
@@ -146,40 +239,68 @@ class AnthropicProvider(LLMProvider):
self.api_key = api_key
self.timeout = timeout
- def generate(self, prompt: str, max_tokens: int) -> Completion:
+ 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 LLM_API_KEY — dostawca anthropic wymaga klucza.")
- data = _post_with_retry(
- f"{self.base_url}/v1/messages",
- {
- "Content-Type": "application/json",
- "x-api-key": self.api_key,
- "anthropic-version": "2023-06-01",
- },
- {
- "model": self.model,
- "max_tokens": max_tokens,
- "messages": [{"role": "user", "content": prompt}],
- },
- self.timeout,
- )
+ 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:
- text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text")
+ 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
- usage = data.get("usage") or {}
- # Anthropic nazywa to inaczej — sprowadzamy do wspólnego kształtu dla diagnostyki
- norm = {"prompt_tokens": usage.get("input_tokens"),
- "completion_tokens": usage.get("output_tokens")}
- text = _require_text(text, {k: v for k, v in norm.items() if v is not None},
- data.get("stop_reason"), max_tokens)
- return Completion(
- text=text, model=data.get("model", self.model), provider=self.name,
- leaves_lan=True, usage=usage,
+
+ 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 LLM_API_KEY",
+ "status": "ok (klucz ustawiony)" if self.api_key else "brak ANTHROPIC_API_KEY",
}
diff --git a/services/logic/app/main.py b/services/logic/app/main.py
index 69f24d8..64ba324 100644
--- a/services/logic/app/main.py
+++ b/services/logic/app/main.py
@@ -129,8 +129,10 @@ class PromptRequest(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
- budget: str = "medium" # concise | medium | extensive
+ 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
@@ -149,7 +151,7 @@ def chart_prompt(req: PromptRequest) -> dict:
"""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
- from app.prompt import build_natal_prompt, build_period_prompt
+ 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)
@@ -157,6 +159,18 @@ def chart_prompt(req: PromptRequest) -> dict:
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 build_provider
+ from app.llm.limits import prompt_token_budget
+ try:
+ prov = build_provider(req.provider)
+ budget_chars = int(prompt_token_budget(prov.name, prov.model) * CHARS_PER_TOKEN)
+ except Exception: # brak klucza/konfiguracji — zapas z tabeli
+ budget_chars = None
+
# 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:
@@ -171,7 +185,7 @@ def chart_prompt(req: PromptRequest) -> dict:
)
except httpx.HTTPError as e:
data_error = f"Warstwa danych niedostępna: {e}"
- out = build_natal_prompt(chart, report, req.budget, label)
+ out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
elif req.profile == "period":
if not (req.from_date and req.to_date):
@@ -191,7 +205,7 @@ def chart_prompt(req: PromptRequest) -> dict:
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)
+ req.budget, label, budget_chars)
else:
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
except ValueError as e: # nieznany budżet
@@ -218,12 +232,26 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
"""
from app.llm.base import LLMError
- from app.llm.factory import build_provider, max_tokens
+ 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)
- result = provider.generate(out["prompt"], req.max_tokens or max_tokens())
+
+ # 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)
diff --git a/services/logic/app/prompt.py b/services/logic/app/prompt.py
index d3888ba..c77fd58 100644
--- a/services/logic/app/prompt.py
+++ b/services/logic/app/prompt.py
@@ -28,8 +28,14 @@ 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
@@ -284,11 +290,16 @@ 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) -> int:
+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]
@@ -305,9 +316,10 @@ def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) ->
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
- moment_label: str | None = None) -> dict:
+ moment_label: str | None = None,
+ budget_chars: int | None = None) -> dict:
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
- limit = _budget_chars(budget)
+ 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))
@@ -316,9 +328,10 @@ def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
- budget: str = DEFAULT_BUDGET, moment_label: str | None = None) -> dict:
+ 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)
+ limit = _budget_chars(budget, budget_chars)
chart_sec = _chart_section(chart, moment_label)
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
diff --git a/services/logic/tests/test_limits.py b/services/logic/tests/test_limits.py
new file mode 100644
index 0000000..1dc867f
--- /dev/null
+++ b/services/logic/tests/test_limits.py
@@ -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
diff --git a/services/logic/tests/test_llm.py b/services/logic/tests/test_llm.py
index f588b94..5ae6bff 100644
--- a/services/logic/tests/test_llm.py
+++ b/services/logic/tests/test_llm.py
@@ -3,6 +3,8 @@
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
deterministyczne i nic nie wychodzi na zewnątrz.
"""
+import json
+
import httpx
import pytest
@@ -115,7 +117,7 @@ def test_anthropic_generates(monkeypatch):
def test_anthropic_requires_key():
- with pytest.raises(LLMError, match="LLM_API_KEY"):
+ with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"):
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
@@ -224,7 +226,7 @@ def test_empty_completion_raises_instead_of_silent_blank(monkeypatch):
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
- with pytest.raises(LLMError, match="pustą odpowiedź"):
+ with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
@@ -238,14 +240,14 @@ def test_empty_completion_explains_context_window(monkeypatch):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
msg = str(ei.value)
assert "kontekstu" in msg and "budżet" in msg
- assert "tokeny promptu=8000" in msg # diagnostyka w tresci bledu
+ 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="pustą odpowiedź"):
+ with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100)
@@ -254,7 +256,7 @@ def test_anthropic_empty_completion_raises(monkeypatch):
"model": "claude-x", "content": [], "stop_reason": "max_tokens",
"usage": {"input_tokens": 9000, "output_tokens": 0},
})))
- with pytest.raises(LLMError, match="pustą odpowiedź"):
+ with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
@@ -265,3 +267,101 @@ def test_normal_response_still_passes(monkeypatch):
"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
diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py
index 3fc4608..7aba5b5 100644
--- a/services/presentation/app/clients/logic_client.py
+++ b/services/presentation/app/clients/logic_client.py
@@ -69,11 +69,15 @@ class LogicClient:
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,
) -> dict[str, Any]:
- """Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt."""
+ """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,
+ "lat": lat, "lon": lon, "budget": budget, "provider": provider,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index 889739e..eedaaf6 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -142,6 +142,7 @@ def interpret_run(
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,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
@@ -191,6 +192,7 @@ def timeline_run(
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,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
diff --git a/services/presentation/app/templates/_prompt_block.html b/services/presentation/app/templates/_prompt_block.html
index bc56d16..5d4c57d 100644
--- a/services/presentation/app/templates/_prompt_block.html
+++ b/services/presentation/app/templates/_prompt_block.html
@@ -7,6 +7,8 @@
+
+
{% endif %}
+ {% if prompt_result.warnings %}
+ {% for w in prompt_result.warnings %}
+ Uwaga: {{ w }}
+ {% endfor %}
+ {% endif %}
+
+ {% if prompt_result.token_plan %}
+ {% set tp = prompt_result.token_plan %}
+
+ 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 %}
+
+ {% endif %}
+
{% if prompt_result.llm_error %}
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}