From f33cdc88f4faa7d274b6b2e12641c590faba449d Mon Sep 17 00:00:00 2001 From: migatu Date: Wed, 22 Jul 2026 18:54:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(llm):=20horoskop=20powstaje=20zawsze=20?= =?UTF-8?q?=E2=80=94=20kontynuacja,=20okna=20kontekstu,=20budzet=20max?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRZYCZYNA PUSTYCH ODPOWIEDZI NA ANTHROPICU (potwierdzona w dokumentacji API): domyslnym modelem byl `claude-sonnet-5`, ktory przy POMINIETYM parametrze `thinking` wlacza myslenie adaptacyjne, a `thinking.display` domyslnie jest "omitted". Tokeny myslenia licza sie do max_tokens, wiec przy LLM_MAX_TOKENS=2000 cala tura wychodzila jako bloki `thinking` z pustym tekstem — parser filtrowal type=="text" i zwracal pusty string. Opus 4.8 bez `thinking` nie mysli, wiec tam objaw by nie wystapil. Gwarancja niepustej odpowiedzi (wszyscy trzej dostawcy): - generate() to teraz PETLA, nie pojedynczy strzal: tura -> jesli urwana na limicie, dopisz ture „kontynuuj" w tej samej rozmowie i sklej tekst, - tura zlozona z samego myslenia traktowana jak urwana (nie jak pustka), - pusta i NIE urwana -> jedna proba z podpowiedzia, dopiero potem blad, - kontynuacja konczy sie tura UZYTKOWNIKA — Claude odrzuca prefill asystenta (400), - `thinking` konfigurowany JAWNIE (adaptive + effort=high; ANTHROPIC_THINKING=off). Okna kontekstu i rezerwa na odpowiedz (app/llm/limits.py): - tabela okien/limitow wyjscia per model + nadpisanie z ENV, - plan() liczy okno odpowiedzi jako okno - prompt - margines i NIGDY nie oddaje calego kontekstu promptowi, - Anthropic liczy tokeny DOKLADNIE (/v1/messages/count_tokens), reszta szacuje, - >90 tys. tokenow promptu -> ostrzezenie, ale wyslanie NADAL mozliwe i z pelnym oknem odpowiedzi. UI: suwak budzetu rozszerzony o „bardzo obszerny" i „maksymalny kontekst modelu" (liczony z okna wybranego modelu po odjeciu rezerwy); przy wyniku widac plan tokenow, liczbe tur i ostrzezenia. Domyslny model Anthropic: claude-opus-4-8. Testy: 170 passed / 1 skipped (logika) + 15 (prezentacja). Nowe testy pokrywaja sklejanie kontynuacji, brak prefillu asystenta, ture z samego myslenia, rezerwe na odpowiedz i prog ostrzezenia. Zweryfikowane e2e na atrapie Anthropica odtwarzajacej zgloszony objaw: 3 tury, obie czesci tekstu obecne. Co-Authored-By: Claude Opus 4.8 --- services/logic/app/llm/factory.py | 6 +- services/logic/app/llm/limits.py | 133 +++++++++ services/logic/app/llm/providers.py | 263 +++++++++++++----- services/logic/app/main.py | 40 ++- services/logic/app/prompt.py | 23 +- services/logic/tests/test_limits.py | 91 ++++++ services/logic/tests/test_llm.py | 110 +++++++- .../presentation/app/clients/logic_client.py | 8 +- services/presentation/app/main.py | 2 + .../app/templates/_prompt_block.html | 19 ++ 10 files changed, 605 insertions(+), 90 deletions(-) create mode 100644 services/logic/app/llm/limits.py create mode 100644 services/logic/tests/test_limits.py 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 @@ + +