fix(llm): pusta odpowiedz modelu to blad, nie pusta strona
build / build (push) Successful in 57s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 13m7s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m57s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 40s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s

Objaw zgloszony przez uzytkownika: w Kalendarzu horoskop wyswietla sie
poprawnie, w Interpretacjach zapytanie wychodzi, wraca — i NIC sie nie
pokazuje. Bez zadnego komunikatu.

Przyczyna: model potrafi oddac pusta tresc (finish_reason=length,
completion_tokens=0), a generate() zwracalo wtedy pusty tekst BEZ bledu.
Widok sprawdza {% if prompt_result.horoscope %} -> falsz -> nie renderuje nic,
a llm_error nie jest ustawiony -> zero wyjasnienia. Cicha awaria.

Asymetria miedzy ekranami wynika z rozmiaru promptu: natalny (13 obiektow x
fasety x opisy z bazy) wypelnia okno kontekstu modelu lokalnego i na odpowiedz
nie zostaje miejsca; okresowy jest mniejszy i sie miesci.

- wspolny straznik _require_text() dla obu dostawcow: pusta lub bialoznakowa
  odpowiedz podnosi LLMError,
- komunikat PROWADZI DO PRZYCZYNY: podaje finish_reason i zuzycie tokenow oraz
  radzi zmniejszyc budzet promptu / zwiekszyc num_ctx / LLM_MAX_TOKENS,
- Anthropic sprowadzony do wspolnego ksztaltu diagnostyki (input/output_tokens).

Dzieki temu uzytkownik widzi powod ORAZ gotowy prompt do recznego uzycia.

Zweryfikowane na zywym stosie z atrapa modelu oddajaca pusta tresc: zamiast
pustej strony pojawia sie pelny komunikat z diagnostyka. Testy: 148 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #17.
This commit is contained in:
2026-07-22 18:37:08 +02:00
committed by gitea
parent 487dbb8fd3
commit 877ec91ff0
2 changed files with 100 additions and 3 deletions
+45 -3
View File
@@ -49,6 +49,39 @@ 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:
"""Pusta odpowiedź modelu MUSI być błędem, nie pustym tekstem.
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 ""
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."
)
raise LLMError(f"Model zwrócił pustą odpowiedź{szczegoly}. {rada}")
class ChatCompletionsProvider(LLMProvider): class ChatCompletionsProvider(LLMProvider):
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI.""" """Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
@@ -78,12 +111,15 @@ class ChatCompletionsProvider(LLMProvider):
self.timeout, self.timeout,
) )
try: try:
text = data["choices"][0]["message"]["content"] choice = data["choices"][0]
text = choice["message"]["content"]
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 {}
text = _require_text(text, usage, choice.get("finish_reason"), max_tokens)
return Completion( return Completion(
text=text, model=data.get("model", self.model), provider=self.name, text=text, model=data.get("model", self.model), provider=self.name,
leaves_lan=self.leaves_lan, usage=data.get("usage") or {}, leaves_lan=self.leaves_lan, usage=usage,
) )
def health(self) -> dict: def health(self) -> dict:
@@ -131,9 +167,15 @@ class AnthropicProvider(LLMProvider):
text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text") text = "".join(b.get("text", "") for b in data["content"] 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
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( return Completion(
text=text, model=data.get("model", self.model), provider=self.name, text=text, model=data.get("model", self.model), provider=self.name,
leaves_lan=True, usage=data.get("usage") or {}, leaves_lan=True, usage=usage,
) )
def health(self) -> dict: def health(self) -> dict:
+55
View File
@@ -210,3 +210,58 @@ def test_cloud_without_key_is_rejected_clearly(monkeypatch):
def test_local_needs_no_key(monkeypatch): def test_local_needs_no_key(monkeypatch):
_clear(monkeypatch) _clear(monkeypatch)
assert factory.build_provider("local").api_key == "" assert factory.build_provider("local").api_key == ""
# ------------------------------------------- pusta odpowiedz modelu (cicha awaria)
# Regresja: model potrafi oddac pusta tresc (prompt zjadl caly kontekst ->
# finish_reason=length, completion_tokens=0). Wczesniej generate() zwracalo pusty
# tekst BEZ bledu, widok nic nie renderowal i uzytkownik dostawal pusta strone
# bez zadnego wyjasnienia. Pusta odpowiedz MUSI byc bledem.
def test_empty_completion_raises_instead_of_silent_blank(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "llama3.1:8b",
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError, match="pustą odpowiedź"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
def test_empty_completion_explains_context_window(monkeypatch):
"""Komunikat ma prowadzic do przyczyny, a nie tylko stwierdzac fakt."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": " "}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError) as ei:
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
msg = str(ei.value)
assert "kontekstu" in msg and "budżet" in msg
assert "tokeny promptu=8000" 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ź"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100)
def test_anthropic_empty_completion_raises(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "claude-x", "content": [], "stop_reason": "max_tokens",
"usage": {"input_tokens": 9000, "output_tokens": 0},
})))
with pytest.raises(LLMError, match="pustą odpowiedź"):
AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
def test_normal_response_still_passes(monkeypatch):
"""Straznik nie moze psuc poprawnej odpowiedzi."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": "Horoskop."}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
})))
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100).text == "Horoskop."