SilverElixir commited on
Commit
e04f308
·
2 Parent(s): 6c0ed5d7bea82f

Merge agent/fix-output-bugs: fix mashed lists and tool-call leak

Browse files
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
- # Прод 05.10.2026: модель склеила тот же список без пробелов вокруг "•" —
156
- # сепаратор ловим regex'ом с необязательными пробелами, плюс "·" тем же путём.
157
  _INLINE_BULLETS_MIN_SEPS = 2
158
- _INLINE_BULLETS_MIN_LEN = 200
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) >= 200
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