From 877ec91ff009afb33ed599569893197b743ad4f0 Mon Sep 17 00:00:00 2001 From: migatu Date: Wed, 22 Jul 2026 18:37:08 +0200 Subject: [PATCH] fix(llm): pusta odpowiedz modelu to blad, nie pusta strona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/logic/app/llm/providers.py | 48 +++++++++++++++++++++++-- services/logic/tests/test_llm.py | 55 +++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/services/logic/app/llm/providers.py b/services/logic/app/llm/providers.py index 8a1bc0f..d3a0c6b 100644 --- a/services/logic/app/llm/providers.py +++ b/services/logic/app/llm/providers.py @@ -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}") +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): """Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI.""" @@ -78,12 +111,15 @@ class ChatCompletionsProvider(LLMProvider): self.timeout, ) try: - text = data["choices"][0]["message"]["content"] + choice = data["choices"][0] + text = choice["message"]["content"] 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=data.get("usage") or {}, + leaves_lan=self.leaves_lan, usage=usage, ) 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") 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=data.get("usage") or {}, + leaves_lan=True, usage=usage, ) def health(self) -> dict: diff --git a/services/logic/tests/test_llm.py b/services/logic/tests/test_llm.py index b515248..f588b94 100644 --- a/services/logic/tests/test_llm.py +++ b/services/logic/tests/test_llm.py @@ -210,3 +210,58 @@ def test_cloud_without_key_is_rejected_clearly(monkeypatch): def test_local_needs_no_key(monkeypatch): _clear(monkeypatch) assert factory.build_provider("local").api_key == "" + + +# ------------------------------------------- pusta odpowiedz modelu (cicha awaria) +# Regresja: model potrafi oddac pusta tresc (prompt zjadl caly kontekst -> +# finish_reason=length, completion_tokens=0). Wczesniej generate() zwracalo pusty +# tekst BEZ bledu, widok nic nie renderowal i uzytkownik dostawal pusta strone +# bez zadnego wyjasnienia. Pusta odpowiedz MUSI byc bledem. + +def test_empty_completion_raises_instead_of_silent_blank(monkeypatch): + monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={ + "model": "llama3.1:8b", + "choices": [{"message": {"content": ""}, "finish_reason": "length"}], + "usage": {"prompt_tokens": 8000, "completion_tokens": 0}, + }))) + with pytest.raises(LLMError, match="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."