fix(characters): retry empty character-sheet extraction with thinking disabled

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-19 22:58:10 +02:00
parent a280ddacde
commit a9b4a493d8
3 changed files with 60 additions and 1 deletions

View File

@ -5,6 +5,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [Unreleased]
### Fixed
- **Character-sheet extraction returned nothing with a reasoning model**: the blocking `/api/character-sheets` call could spend its whole token budget thinking and answer with empty content. It now retries once with thinking switched off (and once more without the switch if the backend rejects it).
---
## [1.34.0] — 2026-09-19 ## [1.34.0] — 2026-09-19
### Added ### Added

View File

@ -1271,6 +1271,22 @@ async def character_sheets(request: Request):
_attribution_llm_lock.release() _attribution_llm_lock.release()
resp.raise_for_status() resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"] _msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or "").strip()
if not raw:
# A reasoning model can spend the whole token budget thinking and return no answer at all
# (the prompt's /no-think is ignored). Ask once more with thinking switched off.
retry_payload = dict(prep["payload"], chat_template_kwargs={"enable_thinking": False})
headers = {"Authorization": f"Bearer {prep['api_key']}"}
if not await asyncio.to_thread(_attribution_llm_lock.acquire, True, prep["timeout_seconds"]):
raise HTTPException(503, "Attribution engine busy")
try:
resp = await asyncio.to_thread(_post_llm_chat_completion, prep["llm_url"], retry_payload, headers, prep["timeout_seconds"])
if resp.status_code in (400, 422): # a backend that rejects the switch
resp = await asyncio.to_thread(_post_llm_chat_completion, prep["llm_url"], prep["payload"], headers, prep["timeout_seconds"])
finally:
_attribution_llm_lock.release()
resp.raise_for_status()
_msg = resp.json()["choices"][0]["message"]
raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip() raw = (_msg.get("content") or _reasoning_text(_msg) or "").strip()
except HTTPException: except HTTPException:
raise raise

View File

@ -254,3 +254,39 @@ class AttributionParserTests(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class CharacterSheetsEmptyAnswerTests(unittest.TestCase):
"""A reasoning model that spends its budget thinking returns no content; the endpoint retries with thinking off."""
def _client(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.conversation as conv
app = FastAPI(); app.include_router(conv.router)
return TestClient(app), conv
def _resp(self, content, status=200):
from unittest import mock
r = mock.Mock(); r.status_code = status; r.raise_for_status = lambda: None
r.json.return_value = {"choices": [{"message": {"content": content, "reasoning_content": "thinking..."}}]}
return r
def test_retries_once_with_thinking_disabled_when_the_answer_is_empty(self):
from unittest import mock
client, conv = self._client()
good = '{"sheets":[{"name":"Linosch","physical":"alter Zwerg"}],"characters":["Linosch"]}'
with mock.patch.object(conv, "_post_llm_chat_completion", side_effect=[self._resp(""), self._resp(good)]) as post:
r = client.post("/api/character-sheets", json={"text": "Linosch trat ein.", "known_characters": ["Linosch"]})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["sheets"][0]["physical"], "alter Zwerg")
self.assertEqual(post.call_count, 2)
self.assertEqual(post.call_args_list[1].args[1].get("chat_template_kwargs"), {"enable_thinking": False})
def test_no_retry_when_the_first_answer_has_content(self):
from unittest import mock
client, conv = self._client()
good = '{"sheets":[{"name":"A","physical":"x"}],"characters":["A"]}'
with mock.patch.object(conv, "_post_llm_chat_completion", return_value=self._resp(good)) as post:
client.post("/api/character-sheets", json={"text": "A kam.", "known_characters": ["A"]})
self.assertEqual(post.call_count, 1)