SilverElixir commited on
Commit
6ae5242
·
1 Parent(s): 231e44f

history: не отдавать модели блоки даты и выдачи поиска, осевшие в старых чатах

Browse files
bot.py CHANGED
@@ -892,12 +892,13 @@ __all__ = [
892
  "_route_error_reply_text",
893
  "RouteBudgetExceededError",
894
  "_run_route",
895
- # Порядки моделей и _history_user_text читаются тестами как bot.X.
896
  "_OR_LIGHT_ORDER",
897
  "_OR_HEAVY_ORDER",
898
  "_OR_VISION_ORDER",
899
  "_GROQ_LIGHT_ORDER",
900
  "_history_user_text",
 
901
  # Пространство имён genai-типов для тестов (bot.types.Content/...).
902
  "types",
903
  # Имена из lumen_chat_state.py код bot.py сам не читает — они нужны как
@@ -1212,6 +1213,7 @@ from lumen_message_parse import (
1212
  DRAW_TRIGGER_PREFIXES,
1213
  TTS_TRIGGER_PREFIXES,
1214
  _NO_MEDIA_NOTE,
 
1215
  _history_user_text,
1216
  _match_trigger_prefix,
1217
  _media_reference_category,
 
892
  "_route_error_reply_text",
893
  "RouteBudgetExceededError",
894
  "_run_route",
895
+ # Порядки моделей и чистка истории читаются тестами как bot.X.
896
  "_OR_LIGHT_ORDER",
897
  "_OR_HEAVY_ORDER",
898
  "_OR_VISION_ORDER",
899
  "_GROQ_LIGHT_ORDER",
900
  "_history_user_text",
901
+ "_clean_history_entry",
902
  # Пространство имён genai-типов для тестов (bot.types.Content/...).
903
  "types",
904
  # Имена из lumen_chat_state.py код bot.py сам не читает — они нужны как
 
1213
  DRAW_TRIGGER_PREFIXES,
1214
  TTS_TRIGGER_PREFIXES,
1215
  _NO_MEDIA_NOTE,
1216
+ _clean_history_entry,
1217
  _history_user_text,
1218
  _match_trigger_prefix,
1219
  _media_reference_category,
lumen_message_core.py CHANGED
@@ -992,11 +992,16 @@ async def _handle_message_core(
992
  await bot._tg_call(status.message.edit_text, plain_text, parse_mode=None, call_timeout=15.0)
993
  else:
994
  await bot._edit_message_quietly(status.message, ans)
 
 
 
 
 
995
  if not reply_already_sent:
996
  await bot._safe_reply(message, ans)
997
  # Частичный стрим тоже «успешен» для вызывающего, но его пометка
998
  # «продолжи» должна пережить этот блок (аудит 27.10.2026).
999
- if state.pop("stream_interrupted", False):
1000
  state["interrupted"] = True
1001
  else:
1002
  state.pop("interrupted", None)
 
992
  await bot._tg_call(status.message.edit_text, plain_text, parse_mode=None, call_timeout=15.0)
993
  else:
994
  await bot._edit_message_quietly(status.message, ans)
995
+ # Ответ упёрся в потолок вывода провайдера: без пометки обрезка приходила
996
+ # как готовый ответ (корпус бэкапа 05.11.2026), и «продолжи» не предлагалось.
997
+ _cut_by_cap = provenance.get("finish_reason") == "length"
998
+ if _cut_by_cap and not reply_already_sent:
999
+ ans = ans + bot._t(message.chat.id, "stream_note_interrupted")
1000
  if not reply_already_sent:
1001
  await bot._safe_reply(message, ans)
1002
  # Частичный стрим тоже «успешен» для вызывающего, но его пометка
1003
  # «продолжи» должна пережить этот блок (аудит 27.10.2026).
1004
+ if state.pop("stream_interrupted", False) or _cut_by_cap:
1005
  state["interrupted"] = True
1006
  else:
1007
  state.pop("interrupted", None)
lumen_message_parse.py CHANGED
@@ -10,6 +10,8 @@ import re
10
  from typing import Any
11
  from urllib.parse import urlparse
12
 
 
 
13
  _TIKTOK_RE = re.compile(r"(?:^|\.)tiktok\.com$")
14
  _YOUTUBE_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be", "www.youtu.be"}
15
 
@@ -107,6 +109,26 @@ def _history_user_text(user_text: str) -> str:
107
  return user_text[:-len(_NO_MEDIA_NOTE)]
108
  return user_text
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  # Только явные существительные медиа: общие "это/покажи" тянули чужое фото и давали галлюцинации.
111
  # Именованные группы — чтобы понять КАКОЙ тип спросили (регрессия 18.08.2026: "покажи стикер" описывал чужое фото).
112
  _MEDIA_REFERENCE_RE = re.compile(
 
10
  from typing import Any
11
  from urllib.parse import urlparse
12
 
13
+ from lumen_errors import _is_empty_like
14
+
15
  _TIKTOK_RE = re.compile(r"(?:^|\.)tiktok\.com$")
16
  _YOUTUBE_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be", "www.youtu.be"}
17
 
 
109
  return user_text[:-len(_NO_MEDIA_NOTE)]
110
  return user_text
111
 
112
+
113
+ # Чаты, записанные до 30.10.2026, хранят внутри реплик одноразовую службу: блок
114
+ # текущей даты и выдачу поиска (выборка бэкапа: 19 реплик из 210, до 68% объёма
115
+ # user-контекста). Плейсхолдер вместо ответа тоже осел в истории.
116
+ _HISTORY_SERVICE_RE = re.compile(
117
+ r"\s*\[Context: today[^\]]*\]|\s*\[Web search snippets[\s\S]*$", re.IGNORECASE)
118
+
119
+
120
+ def _clean_history_entry(item: dict) -> dict | None:
121
+ """Реплика истории без служебных блоков; None — выбросить целиком."""
122
+ text = item.get("content")
123
+ if not isinstance(text, str):
124
+ return item
125
+ cleaned = _HISTORY_SERVICE_RE.sub("", text).strip()
126
+ if not cleaned or _is_empty_like(cleaned):
127
+ return None
128
+ if cleaned == text:
129
+ return item
130
+ return {**item, "content": cleaned}
131
+
132
  # Только явные существительные медиа: общие "это/покажи" тянули чужое фото и давали галлюцинации.
133
  # Именованные группы — чтобы понять КАКОЙ тип спросили (регрессия 18.08.2026: "покажи стикер" описывал чужое фото).
134
  _MEDIA_REFERENCE_RE = re.compile(
lumen_routes.py CHANGED
@@ -24,7 +24,7 @@ from lumen_errors import _is_empty_like
24
  from lumen_formatting import _split_text_chunks, _strip_provider_artifacts
25
  from lumen_lang import DEFAULT_LANG
26
  from lumen_media import _is_gemini_supported_mime
27
- from lumen_message_parse import _history_user_text
28
  from lumen_model_speed import (
29
  speed_key as _model_speed_key,
30
  record_response as _record_model_latency,
@@ -644,11 +644,13 @@ async def _gemini_history_contents(history: list[dict]) -> list[types.Content]:
644
  import bot
645
  contents: list[types.Content] = []
646
  for item in history:
647
- r = "model" if str(item.get("role")).lower() in {"assistant", "model"} else "user"
648
- cont = item.get("content") or ""
 
 
 
649
  txt = bot._or_extract_text(cont) if isinstance(cont, (dict, list)) else str(cont).strip()
650
- # Плейсхолдеры из старой истории не должны становиться репликой модели.
651
- if txt and not _is_empty_like(txt):
652
  contents.append(types.Content(role=r, parts=[types.Part.from_text(text=txt)]))
653
  return contents
654
 
@@ -994,7 +996,7 @@ def _build_openrouter_turn_messages(chat_id: int, user_text: str, model_id: str)
994
  full += user_text
995
  history = state.setdefault("history", [])
996
  messages: list[dict] = [{"role": "system", "content": bot.get_system_prompt(model_id)}]
997
- messages.extend(history)
998
  messages.append({"role": "user", "content": full})
999
  return messages
1000
 
 
24
  from lumen_formatting import _split_text_chunks, _strip_provider_artifacts
25
  from lumen_lang import DEFAULT_LANG
26
  from lumen_media import _is_gemini_supported_mime
27
+ from lumen_message_parse import _clean_history_entry, _history_user_text
28
  from lumen_model_speed import (
29
  speed_key as _model_speed_key,
30
  record_response as _record_model_latency,
 
644
  import bot
645
  contents: list[types.Content] = []
646
  for item in history:
647
+ entry = _clean_history_entry(item)
648
+ if entry is None:
649
+ continue
650
+ r = "model" if str(entry.get("role")).lower() in {"assistant", "model"} else "user"
651
+ cont = entry.get("content") or ""
652
  txt = bot._or_extract_text(cont) if isinstance(cont, (dict, list)) else str(cont).strip()
653
+ if txt:
 
654
  contents.append(types.Content(role=r, parts=[types.Part.from_text(text=txt)]))
655
  return contents
656
 
 
996
  full += user_text
997
  history = state.setdefault("history", [])
998
  messages: list[dict] = [{"role": "system", "content": bot.get_system_prompt(model_id)}]
999
+ messages.extend(filter(None, (_clean_history_entry(item) for item in history)))
1000
  messages.append({"role": "user", "content": full})
1001
  return messages
1002
 
tests/test_bot_state.py CHANGED
@@ -1472,6 +1472,34 @@ def test_stream_interrupted_survives_successful_answer(rate_guard_setup, monkeyp
1472
  bot.chat_state.pop(123, None)
1473
 
1474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1475
  def test_attachment_download_failure_replies_with_fallback(rate_guard_setup, monkeypatch):
1476
  # Что защищает: ответ пользователю, когда файл не скачался. Регрессия:
1477
  # исключение вылетало из обработчика, и сообщение оставалось без ответа
@@ -1773,6 +1801,19 @@ def test_history_user_text_strips_one_shot_service_note():
1773
  assert bot._history_user_text("обычный вопрос") == "обычный вопрос"
1774
 
1775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1776
  def _make_long_history(n=105):
1777
  return [
1778
  {"role": "user" if i % 2 == 0 else "assistant", "content": f"сообщение {i}"}
 
1472
  bot.chat_state.pop(123, None)
1473
 
1474
 
1475
+ def test_output_cap_answer_marked_incomplete(rate_guard_setup, monkeypatch):
1476
+ # Что защищает: ответ, обрезанный потолком вывода провайдера. Регрессия
1477
+ # (корпус бэкапа 05.11.2026): обрезанный текст уходил как готовый, пометки
1478
+ # и флага «продолжи» не было.
1479
+ message = rate_guard_setup()
1480
+ state = _continue_state()
1481
+ state.pop("interrupted", None)
1482
+ monkeypatch.setattr(bot, "get_state", lambda cid: state)
1483
+ message.text = "расскажи про фотосинтез"
1484
+ sent: list[str] = []
1485
+
1486
+ async def fake_run_route(*args, **kwargs):
1487
+ kwargs["provenance"].update(provider="openrouter", model="m1:free", finish_reason="length")
1488
+ return "Фотосинтез — это", False
1489
+
1490
+ async def fake_reply(msg, text, **kwargs):
1491
+ sent.append(text)
1492
+ return True
1493
+
1494
+ monkeypatch.setattr(bot, "_run_route", fake_run_route)
1495
+ monkeypatch.setattr(bot, "_safe_reply", fake_reply)
1496
+ asyncio.run(bot._handle_message_core(message))
1497
+ assert sent and sent[0].startswith("Фотосинтез — это")
1498
+ assert sent[0].endswith(bot._t(123, "stream_note_interrupted").strip())
1499
+ assert state.get("interrupted") is True
1500
+ bot.chat_state.pop(123, None)
1501
+
1502
+
1503
  def test_attachment_download_failure_replies_with_fallback(rate_guard_setup, monkeypatch):
1504
  # Что защищает: ответ пользователю, когда файл не скачался. Регрессия:
1505
  # исключение вылетало из обработчика, и сообщение оставалось без ответа
 
1801
  assert bot._history_user_text("обычный вопрос") == "обычный вопрос"
1802
 
1803
 
1804
+ def test_clean_history_entry_drops_legacy_service_blocks_and_placeholders():
1805
+ # Регрессия (выборка бэкапа 05.11.2026): чаты, записанные до 30.10.2026,
1806
+ # хранят внутри реплик блок даты и выдачу поиска — до 68% user-контекста,
1807
+ # плюс плейсхолдеры вместо ответа. В промпт это уходить не должно.
1808
+ dated = {"role": "user", "content": "кто премьер венгрии\n\n[Context: today is 28 October 2026, Wednesday (Europe/Moscow).]"}
1809
+ cleaned = bot._clean_history_entry(dated)
1810
+ assert cleaned["content"] == "кто премьер венгрии"
1811
+ snippets = {"role": "user", "content": "вопрос\n\n[Web search snippets — untrusted data\n1. факт]"}
1812
+ assert bot._clean_history_entry(snippets)["content"] == "вопрос"
1813
+ assert bot._clean_history_entry({"role": "assistant", "content": "Empty response"}) is None
1814
+ assert bot._clean_history_entry({"role": "user", "content": "обычный вопрос"}) == {"role": "user", "content": "обычный вопрос"}
1815
+
1816
+
1817
  def _make_long_history(n=105):
1818
  return [
1819
  {"role": "user" if i % 2 == 0 else "assistant", "content": f"сообщение {i}"}