fix: retry empty model responses with lower reasoning
This commit is contained in:
1 parent
1dffdcf32d
commit
766ba22f17
4 files changed
+97
-31
No files matched your search
@@ -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`.
|
||||
|
||||
+46
-30
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user