From a9b4a493d8614e8e2b222d1adc2ab64b6b4c1a34 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 19 Sep 2026 22:58:10 +0200 Subject: [PATCH] fix(characters): retry empty character-sheet extraction with thinking disabled Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 +++++++ routes/conversation.py | 18 +++++++++++++++++- tests/test_conversation.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) 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)