diff --git a/README.md b/README.md index 9274b1f..aa069a4 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ SUBSCRIPTION_PRICE_STARS = 100 - SQLite: `/app/data/social_writer.sqlite3` (persistent Coolify volume). - **Per-user данные:** ToV, образцы постов со скриншотов, история сгенерированных постов, история идей. История диалогов и исходные изображения **не сохраняются**. +- **Пустые ответы APInex:** при пустом `content` бот делает до двух повторов; третий запрос добавляет `reasoning_effort: low`. Если ответ всё ещё пустой, операция завершается ошибкой и каждая попытка попадает в API-журнал. Ошибки HTTP автоматически не повторяются. APInex документирует `low`, но не режим отключения reasoning; поддержка параметра зависит от модели. - **Починка формата JSON:** при синтаксически невалидном JSON выполняется не более одной отдельной попытки модели `APINEX_RESPONSE_REPAIR_MODEL`; затем исходные проверки структуры/схемы запускаются заново. Ошибки API, пустые ответы и JSON с неверной схемой автоматически не исправляются. В Coolify development задана `APINEX_RESPONSE_REPAIR_MODEL`. - **API-логи:** JSON запросов/ответов APInex, тело ответа провайдера при ошибке и ошибки разбора хранятся в SQLite 30 дней. Записи связаны `operation_id` и содержат этап, модель, HTTP-статус и длительность. Системные промпты не дублируются — только имя и SHA-256; секреты и base64 изображений маскируются, бинарное аудио не пишется. Пользовательские тексты в запросах/ответах — приватные данные. - **Логи контейнера:** ошибки операций содержат `operation_id`, Telegram user id, этап и полный traceback; ошибки APInex дополнительно показывают модель, статус, длительность и диагностическую структуру ответа без вывода текста пользовательского запроса. Ищи все связанные записи по одному `operation_id`. diff --git a/VERSION b/VERSION index 81f5f58..207b9ea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+34 +0.1.0+35 diff --git a/apinex_client.py b/apinex_client.py index 1303a98..5c839fd 100644 --- a/apinex_client.py +++ b/apinex_client.py @@ -445,40 +445,56 @@ async def chat_text( "temperature": 0.3, "max_tokens": 1800, } - response: dict[str, Any] | None = None - started_at = time.monotonic() - try: - response = await _post_json(APINEX_CHAT_URL, payload) - choices = response.get("choices") or [] - if not choices: - raise RuntimeError("APInex returned no model choices") - content = (choices[0].get("message") or {}).get("content") - if isinstance(content, list): - content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) - if not isinstance(content, str) or not content.strip(): - raise RuntimeError("APInex returned an empty model response") - except Exception as exc: - failure_response: Any = response - provider_error_body = getattr(exc, "response_body", "") - if failure_response is None and provider_error_body: - try: - failure_response = {"provider_error_body": json.loads(provider_error_body)} - except ValueError: - failure_response = {"provider_error_body": provider_error_body} + for attempt in range(3): + attempt_payload = dict(payload) + if attempt == 2: + attempt_payload["reasoning_effort"] = "low" + attempt_log = {**request_for_log, "attempt": attempt + 1} + if attempt == 2: + attempt_log["reasoning_effort"] = "low" + response: dict[str, Any] | None = None + started_at = time.monotonic() + empty_response = False + try: + response = await _post_json(APINEX_CHAT_URL, attempt_payload) + choices = response.get("choices") or [] + if not choices: + raise RuntimeError("APInex returned no model choices") + content = (choices[0].get("message") or {}).get("content") + if isinstance(content, list): + content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) + if not isinstance(content, str) or not content.strip(): + empty_response = True + raise RuntimeError("APInex returned an empty model response") + except Exception as exc: + failure_response: Any = response + provider_error_body = getattr(exc, "response_body", "") + if failure_response is None and provider_error_body: + try: + failure_response = {"provider_error_body": json.loads(provider_error_body)} + except ValueError: + failure_response = {"provider_error_body": provider_error_body} + await _record_api_call( + stage=stage, model=model, prompt_name=prompt_name, system_prompt=system_prompt, + request=attempt_log, response=failure_response, + status_code=_status_from_error(exc) or (200 if response is not None else None), + error=f"{type(exc).__name__}: {exc}", user_id=user_id, + duration_ms=int((time.monotonic() - started_at) * 1000), + ) + if empty_response and attempt < 2: + logger.warning( + "APInex returned empty response operation_id=%s stage=%s attempt=%s; retrying", + current_operation_id() or "-", stage, attempt + 1, + ) + continue + raise await _record_api_call( stage=stage, model=model, prompt_name=prompt_name, system_prompt=system_prompt, - request=request_for_log, response=failure_response, - status_code=_status_from_error(exc) or (200 if response is not None else None), - error=f"{type(exc).__name__}: {exc}", user_id=user_id, + request=attempt_log, response=response, status_code=200, user_id=user_id, duration_ms=int((time.monotonic() - started_at) * 1000), ) - raise - await _record_api_call( - stage=stage, model=model, prompt_name=prompt_name, system_prompt=system_prompt, - request=request_for_log, response=response, status_code=200, user_id=user_id, - duration_ms=int((time.monotonic() - started_at) * 1000), - ) - return content.strip() + return content.strip() + raise RuntimeError("APInex model response retries exhausted") async def classify_intent( diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 82c385d..7447c1f 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -172,6 +172,55 @@ class ApinexTests(unittest.IsolatedAsyncioTestCase): self.assertNotIn("c2VjcmV0LWltYWdlLWJ5dGVz", str(detail["request_json"])) self.assertEqual(detail["stage"], "screenshot_ocr") + async def test_empty_chat_response_retries_then_sets_low_reasoning_effort(self): + responses = [ + FakeResponse(200, {"choices": [{"message": {"content": ""}, "finish_reason": "length"}]}), + FakeResponse(200, {"choices": [{"message": {"content": " "}, "finish_reason": "length"}]}), + FakeResponse(200, {"choices": [{"message": {"content": json.dumps({"posts": ["A", "B", "C"]})}, "finish_reason": "stop"}]}), + ] + sessions = [] + + def make_session(**kwargs): + session = FakeClientSession(responses[len(sessions)], **kwargs) + sessions.append(session) + return session + + with patch.object(apinex_client.aiohttp, "ClientSession", side_effect=make_session): + result = await apinex_client.chat_text( + "APINEX_POST_WRITER_MODEL", "system", "user", stage="post_generation" + ) + + self.assertEqual(json.loads(result), {"posts": ["A", "B", "C"]}) + self.assertEqual(len(sessions), 3) + payloads = [session.request[1]["json"] for session in sessions] + self.assertNotIn("reasoning_effort", payloads[0]) + self.assertNotIn("reasoning_effort", payloads[1]) + self.assertEqual(payloads[2]["reasoning_effort"], "low") + logs = user_store.list_api_call_logs(limit=3) + details = [user_store.get_api_call_log(row["id"]) for row in reversed(logs)] + logged_attempts = [json.loads(str(row["request_json"])) for row in details if row] + self.assertEqual([item["attempt"] for item in logged_attempts], [1, 2, 3]) + self.assertEqual(logged_attempts[2]["reasoning_effort"], "low") + + async def test_empty_chat_response_after_low_reasoning_attempt_raises(self): + sessions = [] + + def make_session(**kwargs): + session = FakeClientSession( + FakeResponse(200, {"choices": [{"message": {"content": ""}, "finish_reason": "length"}]}), + **kwargs, + ) + sessions.append(session) + return session + + with patch.object(apinex_client.aiohttp, "ClientSession", side_effect=make_session): + with self.assertRaisesRegex(RuntimeError, "empty model response"): + await apinex_client.chat_text( + "APINEX_POST_WRITER_MODEL", "system", "user", stage="post_generation" + ) + self.assertEqual(len(sessions), 3) + self.assertEqual(sessions[-1].request[1]["json"]["reasoning_effort"], "low") + async def test_chat_api_failure_is_logged(self): fake_session = FakeClientSession(FakeResponse(401, {"error": {"message": "invalid key"}})) with self.assertLogs(apinex_client.logger, level="ERROR") as captured: