Spaces:
Running
Running
Merge agent/fix-output-bugs: fix mashed lists and tool-call leak
Browse files- lumen_formatting.py +3 -3
- lumen_routes.py +7 -6
- tests/test_bot_routes.py +29 -0
- tests/test_lumen_formatting.py +6 -1
lumen_formatting.py
CHANGED
|
@@ -152,10 +152,10 @@ def _normalize_bullet_markers(text: str) -> str:
|
|
| 152 |
# 2+ разделителя (т.е. уже 3 пункта: прод 26.09.2026 показал, что ровно
|
| 153 |
# трёхпунктовые списки — самый частый случай, а с порогом 3 они не ловились).
|
| 154 |
# Короткие "чай • кофе" (1 разделитель) и короткие абзацы — обычная проза, не трогаем.
|
| 155 |
-
# Прод
|
| 156 |
-
#
|
| 157 |
_INLINE_BULLETS_MIN_SEPS = 2
|
| 158 |
-
_INLINE_BULLETS_MIN_LEN =
|
| 159 |
_INLINE_BULLET_SEP_RE = re.compile(r"\s*[•·]\s*")
|
| 160 |
_INLINE_BULLET_START_RE = re.compile(r"\s*[•·]")
|
| 161 |
|
|
|
|
| 152 |
# 2+ разделителя (т.е. уже 3 пункта: прод 26.09.2026 показал, что ровно
|
| 153 |
# трёхпунктовые списки — самый частый случай, а с порогом 3 они не ловились).
|
| 154 |
# Короткие "чай • кофе" (1 разделитель) и короткие абзацы — обычная проза, не трогаем.
|
| 155 |
+
# Прод 07.10.2026: строки "Telegram: • ..." (~140 символов) не ловились, порог 200→120.
|
| 156 |
+
# Сепаратор с необязательными пробелами ловит склейку без пробелов (прод 05.10.2026).
|
| 157 |
_INLINE_BULLETS_MIN_SEPS = 2
|
| 158 |
+
_INLINE_BULLETS_MIN_LEN = 120
|
| 159 |
_INLINE_BULLET_SEP_RE = re.compile(r"\s*[•·]\s*")
|
| 160 |
_INLINE_BULLET_START_RE = re.compile(r"\s*[•·]")
|
| 161 |
|
lumen_routes.py
CHANGED
|
@@ -695,12 +695,13 @@ async def _extract_gemini_answer_text(resp: Any, *, model_id: str, call_contents
|
|
| 695 |
tool_calls.append(f"response:{json.dumps(bot._json_prune_defaults(getattr(fn_resp, 'response', None)), ensure_ascii=False)}")
|
| 696 |
except Exception:
|
| 697 |
tool_calls.append("response")
|
| 698 |
-
if not ans and tool_calls:
|
| 699 |
-
ans = "[Tool call: " + "; ".join(tool_calls) + "]"
|
| 700 |
-
elif not ans and reasons:
|
| 701 |
retry_failed = False
|
| 702 |
-
if any("MALFORMED_FUNCTION_CALL" in r for r in reasons):
|
| 703 |
-
#
|
|
|
|
|
|
|
|
|
|
| 704 |
try:
|
| 705 |
retry_gconfig = gconfig.model_copy(update={"tools": None}) if gconfig is not None else None
|
| 706 |
# Async-клиент, а не to_thread: wait_for реально отменяет зависший
|
|
@@ -737,7 +738,7 @@ async def _extract_gemini_answer_text(resp: Any, *, model_id: str, call_contents
|
|
| 737 |
# модель, а не показывать заглушку блокировки как готовый ответ
|
| 738 |
# (иначе цепочка останавливалась на первой же битой модели).
|
| 739 |
retry_failed = True
|
| 740 |
-
if not ans and not retry_failed:
|
| 741 |
ans = f"[Ответ заблокирован или пуст. Причина: {', '.join(reasons)}]"
|
| 742 |
# Пустая строка без блокировки — не "Empty response": ask_gemini пробует следующую модель.
|
| 743 |
return ans.strip()
|
|
|
|
| 695 |
tool_calls.append(f"response:{json.dumps(bot._json_prune_defaults(getattr(fn_resp, 'response', None)), ensure_ascii=False)}")
|
| 696 |
except Exception:
|
| 697 |
tool_calls.append("response")
|
| 698 |
+
if not ans and (tool_calls or reasons):
|
|
|
|
|
|
|
| 699 |
retry_failed = False
|
| 700 |
+
if tool_calls or any("MALFORMED_FUNCTION_CALL" in r for r in reasons):
|
| 701 |
+
# Ответ без текста: битый вызов или голый tool_call — пользователю
|
| 702 |
+
# JSON инструментов не показываем (прод 07.10.2026), повторяем без них.
|
| 703 |
+
if tool_calls:
|
| 704 |
+
log.warning("[gemini] Model %s returned only tool calls without text, retrying without tools.", model_id)
|
| 705 |
try:
|
| 706 |
retry_gconfig = gconfig.model_copy(update={"tools": None}) if gconfig is not None else None
|
| 707 |
# Async-клиент, а не to_thread: wait_for реально отменяет зависший
|
|
|
|
| 738 |
# модель, а не показывать заглушку блокировки как готовый ответ
|
| 739 |
# (иначе цепочка останавливалась на первой же битой модели).
|
| 740 |
retry_failed = True
|
| 741 |
+
if not ans and not retry_failed and reasons:
|
| 742 |
ans = f"[Ответ заблокирован или пуст. Причина: {', '.join(reasons)}]"
|
| 743 |
# Пустая строка без блокировки — не "Empty response": ask_gemini пробует следующую модель.
|
| 744 |
return ans.strip()
|
tests/test_bot_routes.py
CHANGED
|
@@ -376,6 +376,35 @@ def test_extract_gemini_answer_reports_non_malformed_block_without_retry_state()
|
|
| 376 |
assert "SAFETY" in ans
|
| 377 |
|
| 378 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
@pytest.mark.parametrize("retry_outcome", ["raises", "empty"])
|
| 380 |
def test_extract_gemini_answer_failed_retry_goes_to_next_model(retry_outcome):
|
| 381 |
# Повтор после битого вызова сам упал (а не просто не влез в бюджет) или
|
|
|
|
| 376 |
assert "SAFETY" in ans
|
| 377 |
|
| 378 |
|
| 379 |
+
def test_extract_gemini_answer_retries_without_tools_on_tool_only_response():
|
| 380 |
+
# Прод 07.10.2026: модель вернула только function_call без текста, юзер видел "[Tool call: ...]".
|
| 381 |
+
from types import SimpleNamespace
|
| 382 |
+
fn_part = SimpleNamespace(
|
| 383 |
+
text="",
|
| 384 |
+
function_call=SimpleNamespace(name="google_search", args={"query": "test"}),
|
| 385 |
+
function_response=None,
|
| 386 |
+
)
|
| 387 |
+
content = SimpleNamespace(parts=[fn_part])
|
| 388 |
+
resp = _FakeGeminiResponse(text="", candidates=[_FakeCandidate(finish_reason="STOP", content=content)])
|
| 389 |
+
|
| 390 |
+
async def retry_ok(*, model, contents, config=None):
|
| 391 |
+
assert config is None or getattr(config, "tools", None) is None
|
| 392 |
+
return _FakeGeminiResponse(text="Ответ без инструментов")
|
| 393 |
+
|
| 394 |
+
fake_client = MagicMock()
|
| 395 |
+
fake_client.aio.models.generate_content = retry_ok
|
| 396 |
+
original_client = bot.client
|
| 397 |
+
bot.client = fake_client
|
| 398 |
+
try:
|
| 399 |
+
ans = asyncio.run(bot._extract_gemini_answer_text(
|
| 400 |
+
resp, model_id="gemini-3.8-flash", call_contents=[], gconfig=None,
|
| 401 |
+
))
|
| 402 |
+
assert ans == "Ответ без инструментов"
|
| 403 |
+
assert "Tool call" not in ans
|
| 404 |
+
finally:
|
| 405 |
+
bot.client = original_client
|
| 406 |
+
|
| 407 |
+
|
| 408 |
@pytest.mark.parametrize("retry_outcome", ["raises", "empty"])
|
| 409 |
def test_extract_gemini_answer_failed_retry_goes_to_next_model(retry_outcome):
|
| 410 |
# Повтор после битого вызова сам упал (а не просто не влез в бюджет) или
|
tests/test_lumen_formatting.py
CHANGED
|
@@ -351,9 +351,14 @@ def test_split_inline_bullets_ignores_short_prose():
|
|
| 351 |
"для длины строки и смыслом · третий пункт с подробностями для длины строки и смыслом · четвёртый пункт.",
|
| 352 |
4, "Итоги такие: ",
|
| 353 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
])
|
| 355 |
def test_split_inline_bullets_splits_prod_answers(text, n_lines, intro):
|
| 356 |
-
assert len(text) >=
|
| 357 |
lines = lumen_formatting._split_inline_bullets(text).split("\n")
|
| 358 |
assert len(lines) == n_lines
|
| 359 |
assert "" not in lines
|
|
|
|
| 351 |
"для длины строки и смыслом · третий пункт с подробностями для длины строки и смыслом · четвёртый пункт.",
|
| 352 |
4, "Итоги такие: ",
|
| 353 |
),
|
| 354 |
+
# Прод 07.10.2026: строки платформ (~150 символов) не ловились порогом 200.
|
| 355 |
+
(
|
| 356 |
+
"Telegram: • Поищи канал по названию и нажми вступить для проверки длины строки • Часто приватные каналы шлют приглашения в личку",
|
| 357 |
+
3, "Telegram:",
|
| 358 |
+
),
|
| 359 |
])
|
| 360 |
def test_split_inline_bullets_splits_prod_answers(text, n_lines, intro):
|
| 361 |
+
assert len(text) >= 120
|
| 362 |
lines = lumen_formatting._split_inline_bullets(text).split("\n")
|
| 363 |
assert len(lines) == n_lines
|
| 364 |
assert "" not in lines
|