fix: retry empty model responses with lower reasoning

This commit is contained in:
admin committed 2026-10-08 22:12:18 +02:00
1 parent 1dffdcf32d
commit 766ba22f17
4 files changed
+97 -31

No files matched your search

+1
View File
@@ -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`.
+1 -1
View File
@@ -1 +1 @@
0.1.0+34
0.1.0+35
+46 -30
View File
@@ -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(
+49
View File
@@ -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: