diff --git a/CHANGELOG.md b/CHANGELOG.md index 098063d..2c438b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Added diff --git a/routes/conversation.py b/routes/conversation.py index 4b275aa..3b7c23f 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -1271,7 +1271,23 @@ async def character_sheets(request: Request): _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 "").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() except HTTPException: raise except Exception as e: diff --git a/tests/test_conversation.py b/tests/test_conversation.py index f19c213..3a60636 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -254,3 +254,39 @@ class AttributionParserTests(unittest.TestCase): if __name__ == "__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)