Spaces:
Running
Running
SilverElixir commited on
Commit ·
6ae5242
1
Parent(s): 231e44f
history: не отдавать модели блоки даты и выдачи поиска, осевшие в старых чатах
Browse files- bot.py +3 -1
- lumen_message_core.py +6 -1
- lumen_message_parse.py +22 -0
- lumen_routes.py +8 -6
- tests/test_bot_state.py +41 -0
bot.py
CHANGED
|
@@ -892,12 +892,13 @@ __all__ = [
|
|
| 892 |
"_route_error_reply_text",
|
| 893 |
"RouteBudgetExceededError",
|
| 894 |
"_run_route",
|
| 895 |
-
# Порядки моделей и
|
| 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 |
-
|
| 648 |
-
|
|
|
|
|
|
|
|
|
|
| 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}"}
|