feat(llm): horoskop powstaje zawsze — kontynuacja, okna kontekstu, budzet max
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 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,11 @@ PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
|
|||||||
_DEFAULT_MODEL = {
|
_DEFAULT_MODEL = {
|
||||||
LOCAL: "llama3.1:8b",
|
LOCAL: "llama3.1:8b",
|
||||||
OPENAI: "gpt-4o-mini",
|
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 = {
|
_DEFAULT_URL = {
|
||||||
# Ollama i vLLM wystawiają zgodne API pod /v1
|
# Ollama i vLLM wystawiają zgodne API pod /v1
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
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
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -17,6 +32,23 @@ from app.llm.base import Completion, LLMError, LLMProvider
|
|||||||
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||||
MAX_ATTEMPTS = 3
|
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:
|
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
|
||||||
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
|
"""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)
|
time.sleep(2 ** attempt)
|
||||||
continue
|
continue
|
||||||
raise LLMError(
|
raise LLMError(
|
||||||
f"Model nie odpowiedział w czasie {timeout:.0f}s. Dłuższe horoskopy "
|
f"Model nie odpowiedział w czasie {timeout:.0f}s. Zwiększ LLM_TIMEOUT "
|
||||||
f"wymagają większego LLM_TIMEOUT albo mniejszego budżetu promptu."
|
f"albo zmniejsz budżet promptu."
|
||||||
) from e
|
) from e
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
last = 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}")
|
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:
|
def _merge_usage(total: dict, turn: dict) -> dict:
|
||||||
"""Pusta odpowiedź modelu MUSI być błędem, nie pustym tekstem.
|
"""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 = []
|
def _join(parts: list[str]) -> str:
|
||||||
if finish_reason:
|
return "".join(parts).strip()
|
||||||
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 ""
|
|
||||||
|
|
||||||
rada = (
|
|
||||||
"Najczęstsza przyczyna: prompt nie zmieścił się w oknie kontekstu modelu i na "
|
def _explain_empty(turns: int, usage: dict, stop: str | None) -> str:
|
||||||
"odpowiedź nie zostało miejsca. Zmniejsz budżet promptu (zwięzły), zwiększ okno "
|
detail = []
|
||||||
"kontekstu modelu (w Ollamie num_ctx) albo podnieś LLM_MAX_TOKENS."
|
if stop:
|
||||||
if finish_reason == "length" or (usage or {}).get("completion_tokens") == 0
|
detail.append(f"powód zakończenia: {stop}")
|
||||||
else "Model przyjął żądanie, ale nie wygenerował treści."
|
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."""
|
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
|
||||||
|
|
||||||
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
|
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}"
|
h["Authorization"] = f"Bearer {self.api_key}"
|
||||||
return h
|
return h
|
||||||
|
|
||||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
def _turn(self, messages: list[dict], max_tokens: int):
|
||||||
data = _post_with_retry(
|
data = _post_with_retry(
|
||||||
f"{self.base_url}/chat/completions", self._headers(),
|
f"{self.base_url}/chat/completions", self._headers(),
|
||||||
{
|
{"model": self.model, "max_tokens": max_tokens, "messages": messages},
|
||||||
"model": self.model,
|
|
||||||
"max_tokens": max_tokens,
|
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
|
||||||
},
|
|
||||||
self.timeout,
|
self.timeout,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
choice = data["choices"][0]
|
choice = data["choices"][0]
|
||||||
text = choice["message"]["content"]
|
text = choice["message"].get("content") or ""
|
||||||
except (KeyError, IndexError, TypeError) as e:
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||||
usage = data.get("usage") or {}
|
stop = choice.get("finish_reason")
|
||||||
text = _require_text(text, usage, choice.get("finish_reason"), max_tokens)
|
return (text, stop == "length", data.get("usage") or {},
|
||||||
return Completion(
|
data.get("model", self.model), stop)
|
||||||
text=text, model=data.get("model", self.model), provider=self.name,
|
|
||||||
leaves_lan=self.leaves_lan, usage=usage,
|
|
||||||
)
|
|
||||||
|
|
||||||
def health(self) -> dict:
|
def health(self) -> dict:
|
||||||
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
||||||
@@ -133,7 +226,7 @@ class ChatCompletionsProvider(LLMProvider):
|
|||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
class AnthropicProvider(LLMProvider):
|
class AnthropicProvider(_Driver, LLMProvider):
|
||||||
"""Protokół Anthropic `/v1/messages`."""
|
"""Protokół Anthropic `/v1/messages`."""
|
||||||
|
|
||||||
leaves_lan = True
|
leaves_lan = True
|
||||||
@@ -146,40 +239,68 @@ class AnthropicProvider(LLMProvider):
|
|||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.timeout = timeout
|
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:
|
if not self.api_key:
|
||||||
raise LLMError("Brak LLM_API_KEY — dostawca anthropic wymaga klucza.")
|
raise LLMError("Brak ANTHROPIC_API_KEY — dostawca anthropic wymaga klucza.")
|
||||||
data = _post_with_retry(
|
payload = {"model": self.model, "max_tokens": max_tokens, "messages": messages}
|
||||||
f"{self.base_url}/v1/messages",
|
payload.update(self._thinking())
|
||||||
{
|
data = _post_with_retry(f"{self.base_url}/v1/messages", self._headers(),
|
||||||
"Content-Type": "application/json",
|
payload, self.timeout)
|
||||||
"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,
|
|
||||||
)
|
|
||||||
try:
|
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:
|
except (KeyError, TypeError) as e:
|
||||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from 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
|
stop = data.get("stop_reason")
|
||||||
norm = {"prompt_tokens": usage.get("input_tokens"),
|
# tura złożona z samego myślenia = budżet poszedł na rozumowanie; traktujemy
|
||||||
"completion_tokens": usage.get("output_tokens")}
|
# jak ucięcie, żeby pętla poprosiła o treść zamiast zwrócić pustkę
|
||||||
text = _require_text(text, {k: v for k, v in norm.items() if v is not None},
|
thinking_only = not text.strip() and any(
|
||||||
data.get("stop_reason"), max_tokens)
|
b.get("type") in ("thinking", "redacted_thinking") for b in blocks
|
||||||
return Completion(
|
|
||||||
text=text, model=data.get("model", self.model), provider=self.name,
|
|
||||||
leaves_lan=True, usage=usage,
|
|
||||||
)
|
)
|
||||||
|
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:
|
def health(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"provider": self.name, "model": self.model, "leaves_lan": True,
|
"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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,8 +129,10 @@ class PromptRequest(BaseModel):
|
|||||||
when_utc: datetime
|
when_utc: datetime
|
||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
lon: float = 0.0
|
lon: float = 0.0
|
||||||
budget: str = "medium" # concise | medium | extensive
|
budget: str = "medium" # concise | medium | extensive | huge | max
|
||||||
limit: int = 5000
|
limit: int = 5000
|
||||||
|
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
|
||||||
|
model: str | None = None
|
||||||
# tylko dla profilu period:
|
# tylko dla profilu period:
|
||||||
from_date: str | None = None
|
from_date: str | None = None
|
||||||
to_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.chart import build_chart
|
||||||
from app.engine.models import ChartMoment
|
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()
|
engine = get_engine()
|
||||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
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")
|
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
data_error = None
|
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ą
|
# 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.
|
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
|
||||||
try:
|
try:
|
||||||
@@ -171,7 +185,7 @@ def chart_prompt(req: PromptRequest) -> dict:
|
|||||||
)
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
data_error = f"Warstwa danych niedostępna: {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":
|
elif req.profile == "period":
|
||||||
if not (req.from_date and req.to_date):
|
if not (req.from_date and req.to_date):
|
||||||
@@ -191,7 +205,7 @@ def chart_prompt(req: PromptRequest) -> dict:
|
|||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
||||||
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
||||||
req.budget, label)
|
req.budget, label, budget_chars)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
||||||
except ValueError as e: # nieznany budżet
|
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ć.
|
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
||||||
"""
|
"""
|
||||||
from app.llm.base import LLMError
|
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
|
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
||||||
try:
|
try:
|
||||||
provider = build_provider(req.provider)
|
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:
|
except LLMError as e:
|
||||||
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
||||||
out["llm_error"] = str(e)
|
out["llm_error"] = str(e)
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ BUDGETS: dict[str, int] = {
|
|||||||
"concise": 4_000,
|
"concise": 4_000,
|
||||||
"medium": 12_000,
|
"medium": 12_000,
|
||||||
"extensive": 30_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"
|
DEFAULT_BUDGET = "medium"
|
||||||
|
MAX_BUDGET = "max"
|
||||||
|
|
||||||
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
|
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
|
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}"])
|
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:
|
if budget not in BUDGETS:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
|
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
|
||||||
)
|
)
|
||||||
|
if budget_chars and budget_chars > 0:
|
||||||
|
return budget_chars
|
||||||
return BUDGETS[budget]
|
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,
|
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")."""
|
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
|
||||||
limit = _budget_chars(budget)
|
limit = _budget_chars(budget, budget_chars)
|
||||||
chart_sec = _chart_section(chart, moment_label)
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
|
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
|
||||||
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
|
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,
|
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")."""
|
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
|
||||||
limit = _budget_chars(budget)
|
limit = _budget_chars(budget, budget_chars)
|
||||||
chart_sec = _chart_section(chart, moment_label)
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
|
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
|
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
|
||||||
deterministyczne i nic nie wychodzi na zewnątrz.
|
deterministyczne i nic nie wychodzi na zewnątrz.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ def test_anthropic_generates(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_anthropic_requires_key():
|
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)
|
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"}],
|
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
|
||||||
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
|
"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)
|
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)
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
|
||||||
msg = str(ei.value)
|
msg = str(ei.value)
|
||||||
assert "kontekstu" in msg and "budżet" in msg
|
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):
|
def test_whitespace_only_is_treated_as_empty(monkeypatch):
|
||||||
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||||
"choices": [{"message": {"content": "\n\n \t "}}],
|
"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)
|
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",
|
"model": "claude-x", "content": [], "stop_reason": "max_tokens",
|
||||||
"usage": {"input_tokens": 9000, "output_tokens": 0},
|
"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)
|
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},
|
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
|
||||||
})))
|
})))
|
||||||
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100).text == "Horoskop."
|
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
|
||||||
|
|||||||
@@ -69,11 +69,15 @@ class LogicClient:
|
|||||||
def prompt(
|
def prompt(
|
||||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||||
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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] = {
|
payload: dict[str, Any] = {
|
||||||
"profile": profile, "when_utc": when_utc_iso,
|
"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:
|
if from_date and to_date:
|
||||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ def interpret_run(
|
|||||||
if action == "prompt":
|
if action == "prompt":
|
||||||
ctx["prompt_result"] = logic.prompt(
|
ctx["prompt_result"] = logic.prompt(
|
||||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
||||||
|
provider=llm_provider,
|
||||||
)
|
)
|
||||||
elif action == "horoscope":
|
elif action == "horoscope":
|
||||||
ctx["prompt_result"] = logic.horoscope(
|
ctx["prompt_result"] = logic.horoscope(
|
||||||
@@ -191,6 +192,7 @@ def timeline_run(
|
|||||||
ctx["prompt_result"] = logic.prompt(
|
ctx["prompt_result"] = logic.prompt(
|
||||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||||||
|
provider=llm_provider,
|
||||||
)
|
)
|
||||||
elif action == "horoscope":
|
elif action == "horoscope":
|
||||||
ctx["prompt_result"] = logic.horoscope(
|
ctx["prompt_result"] = logic.horoscope(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
<option value="concise" {{ 'selected' if pb == 'concise' else '' }}>zwięzły (~4 tys. znaków)</option>
|
<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="medium" {{ 'selected' if pb == 'medium' else '' }}>średni (~12 tys.)</option>
|
||||||
<option value="extensive" {{ 'selected' if pb == 'extensive' else '' }}>obszerny (~30 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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Model
|
<label>Model
|
||||||
@@ -51,6 +53,23 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if prompt_result.llm_error %}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
||||||
|
|||||||
Reference in New Issue
Block a user