Spaces:
Running
Running
SilverElixir commited on
Commit ·
8cdce5e
1
Parent(s): e3b8ac4
Keep reconciled stream tails formatted, tighten freshness word boundaries and probe real TikWM API paths
Browse files- bot.py +13 -15
- lumen_admin.py +9 -4
- lumen_commands.py +7 -4
- lumen_router_config.py +7 -4
- lumen_streaming.py +7 -2
- tests/test_bot_admin.py +8 -0
- tests/test_bot_streaming.py +6 -3
- tests/test_lumen_router_config.py +9 -0
bot.py
CHANGED
|
@@ -1778,21 +1778,19 @@ async def main() -> None:
|
|
| 1778 |
# Флаги — канонически в lumen_chat_state (P2): множества общие объектом,
|
| 1779 |
# bool-флаги читаем из модуля, т.к. bot-привязки после выноса stale.
|
| 1780 |
# Синхронная запись в потоке: loop уже дренирует задачи, висеть нельзя.
|
| 1781 |
-
|
| 1782 |
-
|
| 1783 |
-
|
| 1784 |
-
|
| 1785 |
-
|
| 1786 |
-
|
| 1787 |
-
|
| 1788 |
-
|
| 1789 |
-
|
| 1790 |
-
|
| 1791 |
-
|
| 1792 |
-
|
| 1793 |
-
|
| 1794 |
-
except asyncio.TimeoutError:
|
| 1795 |
-
log.warning('[state] Final quota save exceeded 10s; abandoning the remainder.')
|
| 1796 |
await _close_sessions()
|
| 1797 |
|
| 1798 |
if __name__ == "__main__":
|
|
|
|
| 1778 |
# Флаги — канонически в lumen_chat_state (P2): множества общие объектом,
|
| 1779 |
# bool-флаги читаем из модуля, т.к. bot-привязки после выноса stale.
|
| 1780 |
# Синхронная запись в потоке: loop уже дренирует задачи, висеть нельзя.
|
| 1781 |
+
# Стартовая загрузка могла не удаться частично: чаты сохраняем (это
|
| 1782 |
+
# единственная копия новых сообщений), а индекс и квота внутри сами
|
| 1783 |
+
# пропускаются, чтобы не затереть удалённые данные (аудит A5-2).
|
| 1784 |
+
if _dirty_chat_ids or lumen_chat_state._index_dirty or _pending_chat_deletions:
|
| 1785 |
+
try:
|
| 1786 |
+
await asyncio.wait_for(asyncio.to_thread(_flush_state_now), timeout=15.0)
|
| 1787 |
+
except asyncio.TimeoutError:
|
| 1788 |
+
log.warning('[state] Final flush exceeded 15s; abandoning the remainder.')
|
| 1789 |
+
if lumen_chat_state._quota_dirty:
|
| 1790 |
+
try:
|
| 1791 |
+
await asyncio.wait_for(asyncio.to_thread(save_global_quota), timeout=10.0)
|
| 1792 |
+
except asyncio.TimeoutError:
|
| 1793 |
+
log.warning('[state] Final quota save exceeded 10s; abandoning the remainder.')
|
|
|
|
|
|
|
| 1794 |
await _close_sessions()
|
| 1795 |
|
| 1796 |
if __name__ == "__main__":
|
lumen_admin.py
CHANGED
|
@@ -222,7 +222,7 @@ async def probe_url(
|
|
| 222 |
# обрезан; 401 самого прокси — тело "Unauthorized" и остаётся красным.
|
| 223 |
with contextlib.suppress(Exception):
|
| 224 |
body = await resp.content.read(512)
|
| 225 |
-
if b'"error_code"' in body:
|
| 226 |
ok = True
|
| 227 |
return {"status": resp.status, "elapsed_sec": round(time.time() - start, 2), "ok": ok}
|
| 228 |
except Exception as exc:
|
|
@@ -247,7 +247,11 @@ async def network_diagnostics(request: Request) -> dict[str, Any]:
|
|
| 247 |
_log_denied(request, "GET /diag")
|
| 248 |
return _forbidden()
|
| 249 |
import bot
|
|
|
|
| 250 |
bot_token = bot.BOT_TOKEN
|
|
|
|
|
|
|
|
|
|
| 251 |
targets = {
|
| 252 |
"telegram_api": "https://api.telegram.org",
|
| 253 |
"telegram_file_api": "https://api.telegram.org/bot" + (bot_token[:6] if bot_token else "x") + "/getMe",
|
|
@@ -263,8 +267,9 @@ async def network_diagnostics(request: Request) -> dict[str, Any]:
|
|
| 263 |
"huggingface": "https://huggingface.co",
|
| 264 |
"openrouter": "https://openrouter.ai",
|
| 265 |
"groq_api": "https://api.groq.com",
|
| 266 |
-
#
|
| 267 |
-
|
|
|
|
| 268 |
"pollinations": "https://image.pollinations.ai",
|
| 269 |
"upstash": bot.UPSTASH_REDIS_REST_URL if bot.USE_UPSTASH else "https://upstash.com",
|
| 270 |
}
|
|
@@ -283,7 +288,7 @@ async def network_diagnostics(request: Request) -> dict[str, Any]:
|
|
| 283 |
headers = None
|
| 284 |
if name == "configured_tg_proxy" and bot.TELEGRAM_API_BASE_URL != "https://api.telegram.org":
|
| 285 |
headers = proxy_headers
|
| 286 |
-
elif name == "tikwm" and
|
| 287 |
headers = proxy_headers
|
| 288 |
return name, await probe_url(
|
| 289 |
session, url, timeout_sec=6.0, redact=bot_token or "", headers=headers,
|
|
|
|
| 222 |
# обрезан; 401 самого прокси — тело "Unauthorized" и остаётся красным.
|
| 223 |
with contextlib.suppress(Exception):
|
| 224 |
body = await resp.content.read(512)
|
| 225 |
+
if b'"error_code"' in body and b'"description"' in body:
|
| 226 |
ok = True
|
| 227 |
return {"status": resp.status, "elapsed_sec": round(time.time() - start, 2), "ok": ok}
|
| 228 |
except Exception as exc:
|
|
|
|
| 247 |
_log_denied(request, "GET /diag")
|
| 248 |
return _forbidden()
|
| 249 |
import bot
|
| 250 |
+
from urllib.parse import urlsplit
|
| 251 |
bot_token = bot.BOT_TOKEN
|
| 252 |
+
tikwm_base = bot.TIKWM_API_BASE_URL
|
| 253 |
+
# Прямой хост TikWM секрет не получает: он нужен только прокси.
|
| 254 |
+
tikwm_via_proxy = bool(tikwm_base) and urlsplit(tikwm_base).hostname not in {"www.tikwm.com", "tikwm.com"}
|
| 255 |
targets = {
|
| 256 |
"telegram_api": "https://api.telegram.org",
|
| 257 |
"telegram_file_api": "https://api.telegram.org/bot" + (bot_token[:6] if bot_token else "x") + "/getMe",
|
|
|
|
| 267 |
"huggingface": "https://huggingface.co",
|
| 268 |
"openrouter": "https://openrouter.ai",
|
| 269 |
"groq_api": "https://api.groq.com",
|
| 270 |
+
# /api/ — тот же путь, что у реальных запросов: корень прокси мог
|
| 271 |
+
# отвечать 200, пока сам API-путь сломан (ревью 28.10.2026).
|
| 272 |
+
"tikwm": (tikwm_base + "/api/") if tikwm_via_proxy else "https://www.tikwm.com",
|
| 273 |
"pollinations": "https://image.pollinations.ai",
|
| 274 |
"upstash": bot.UPSTASH_REDIS_REST_URL if bot.USE_UPSTASH else "https://upstash.com",
|
| 275 |
}
|
|
|
|
| 288 |
headers = None
|
| 289 |
if name == "configured_tg_proxy" and bot.TELEGRAM_API_BASE_URL != "https://api.telegram.org":
|
| 290 |
headers = proxy_headers
|
| 291 |
+
elif name == "tikwm" and tikwm_via_proxy:
|
| 292 |
headers = proxy_headers
|
| 293 |
return name, await probe_url(
|
| 294 |
session, url, timeout_sec=6.0, redact=bot_token or "", headers=headers,
|
lumen_commands.py
CHANGED
|
@@ -764,13 +764,16 @@ async def cmd_selftest(message: Message) -> None:
|
|
| 764 |
proxy_url = bot.TELEGRAM_API_BASE_URL + "/bot" + (token[:6] if token else "x") + "/getMe"
|
| 765 |
proxy_headers = {"X-Lumen-Proxy-Secret": bot.LUMEN_PROXY_SECRET} if bot.LUMEN_PROXY_SECRET else None
|
| 766 |
tg_headers = proxy_headers if bot.TELEGRAM_API_BASE_URL != "https://api.telegram.org" else None
|
| 767 |
-
# TikWM из HF доступен только через прокси: проверяем настроенный адрес
|
| 768 |
-
# а не прямой хост
|
| 769 |
-
|
|
|
|
|
|
|
|
|
|
| 770 |
net = await asyncio.gather(
|
| 771 |
bot.probe_url(session, proxy_url, timeout_sec=6.0, redact=token, headers=tg_headers),
|
| 772 |
bot.probe_url(
|
| 773 |
-
session,
|
| 774 |
timeout_sec=6.0, headers=tikwm_headers,
|
| 775 |
),
|
| 776 |
)
|
|
|
|
| 764 |
proxy_url = bot.TELEGRAM_API_BASE_URL + "/bot" + (token[:6] if token else "x") + "/getMe"
|
| 765 |
proxy_headers = {"X-Lumen-Proxy-Secret": bot.LUMEN_PROXY_SECRET} if bot.LUMEN_PROXY_SECRET else None
|
| 766 |
tg_headers = proxy_headers if bot.TELEGRAM_API_BASE_URL != "https://api.telegram.org" else None
|
| 767 |
+
# TikWM из HF доступен только через прокси: проверяем настроенный адрес
|
| 768 |
+
# и путь /api/, как у реальных запросов, а не прямой хост (аудит 28.10.2026).
|
| 769 |
+
from urllib.parse import urlsplit
|
| 770 |
+
tikwm_base = bot.TIKWM_API_BASE_URL
|
| 771 |
+
tikwm_via_proxy = bool(tikwm_base) and urlsplit(tikwm_base).hostname not in {"www.tikwm.com", "tikwm.com"}
|
| 772 |
+
tikwm_headers = proxy_headers if tikwm_via_proxy else None
|
| 773 |
net = await asyncio.gather(
|
| 774 |
bot.probe_url(session, proxy_url, timeout_sec=6.0, redact=token, headers=tg_headers),
|
| 775 |
bot.probe_url(
|
| 776 |
+
session, (tikwm_base + "/api/") if tikwm_via_proxy else "https://www.tikwm.com",
|
| 777 |
timeout_sec=6.0, headers=tikwm_headers,
|
| 778 |
),
|
| 779 |
)
|
lumen_router_config.py
CHANGED
|
@@ -560,7 +560,8 @@ _FRESHNESS_QUERY_RE = re.compile(
|
|
| 560 |
# не-RU/EN свежие вопросы уходили в обычный текст без даты (аудит 28.10.2026).
|
| 561 |
# Без tiempo/tempo/weer/son/panahon/morgen: они значат ещё «время», «снова»,
|
| 562 |
# «конец», «утро» — ложные срабатывания на обычной болтовне дороже пропуска.
|
| 563 |
-
|
|
|
|
| 564 |
r"|\b(heute|jetzt|aktuell|neueste|nachrichten|preis|wetter|vorhersage|gestern)\b"
|
| 565 |
r"|\b(aujourd'hui|maintenant|actuel|récent|actualités|prix|météo|prévisions|demain|hier)\b"
|
| 566 |
r"|\b(oggi|adesso|attuale|recente|notizie|prezzo|meteo|previsioni|domani|ieri)\b"
|
|
@@ -570,7 +571,9 @@ _FRESHNESS_QUERY_RE = re.compile(
|
|
| 570 |
r"|\b(dziś|teraz|aktualn\w*|ostatni\w*|wiadomości|cena|pogoda|prognoza|jutro|wczoraj)\b"
|
| 571 |
r"|\b(bugün|şimdi|güncel|haberler|fiyat|hava|tahmini|yarın|dün)\b"
|
| 572 |
r"|\b(اليوم|الآن|الحالي|الأخير|أخبار|سعر|الطقس|توقعات|غدا|أمس)\b"
|
| 573 |
-
|
|
|
|
|
|
|
| 574 |
r"|\b(hari ini|sekarang|terbaru|berita|harga|cuaca|prakiraan|besok|kemarin)\b"
|
| 575 |
r"|\b(hari ini|sekarang|terkini|berita|harga|cuaca|ramalan|esok|semalam)\b"
|
| 576 |
r"|\b(hôm nay|bây giờ|hiện tại|mới nhất|tin tức|giá|thời tiết|dự báo|ngày mai|hôm qua)\b"
|
|
@@ -578,13 +581,13 @@ _FRESHNESS_QUERY_RE = re.compile(
|
|
| 578 |
r"|\b(امروز|اکنون|فعلی|اخیر|اخبار|قیمت|هوا|پیشبینی|فردا|دیروز)\b"
|
| 579 |
r"|\b(آج|ابھی|موجودہ|حالیہ|خبریں|قیمت|موسم|پیش گوئی|کل)\b"
|
| 580 |
r"|\b(bugun|hozir|joriy|so'nggi|yangiliklar|narx|ob-havo|prognoz|ertaga|kecha)\b"
|
| 581 |
-
r"|(আজ|এখন|বর্তমান|সাম্প্রতিক|সংবাদ|খবর|দাম|আবহাওয়া|পূর্বাভাস|আগামীকাল|গতকাল)"
|
| 582 |
r"|\b(ngayon|kasalukuyan|pinakabago|balita|presyo|bukas|kahapon)\b"
|
| 583 |
r"|\b(namuhla|manje|okwamanje|kwakamuva|izindaba|intengo|isimo sezulu|isibikezelo|kusasa|izolo)\b"
|
| 584 |
r"|\b(сёння|зараз|актуальн\w*|апошн\w*|навіны|кошт|цана|надвор'е|прагноз|заўтра|учора)\b"
|
| 585 |
r"|\b(сьогодні|зараз|актуальн\w*|останн\w*|новини|ціна|погода|прогноз|завтра|вчора)\b"
|
| 586 |
r"|\b(бүгін|қазір|ағымдағы|соңғы|жаңалықтар|баға|ауа райы|болжам|ертең|кеше)\b"
|
| 587 |
-
r"|завтра|вчера",
|
| 588 |
re.IGNORECASE,
|
| 589 |
)
|
| 590 |
|
|
|
|
| 560 |
# не-RU/EN свежие вопросы уходили в обычный текст без даты (аудит 28.10.2026).
|
| 561 |
# Без tiempo/tempo/weer/son/panahon/morgen: они значат ещё «время», «снова»,
|
| 562 |
# «конец», «утро» — ложные срабатывания на обычной болтовне дороже пропуска.
|
| 563 |
+
# actual убран: в испанском «текущий», но в английском это обычное «фактический».
|
| 564 |
+
r"|\b(hoy|ahora|reciente|noticias|precio|clima|mañana|ayer)\b"
|
| 565 |
r"|\b(heute|jetzt|aktuell|neueste|nachrichten|preis|wetter|vorhersage|gestern)\b"
|
| 566 |
r"|\b(aujourd'hui|maintenant|actuel|récent|actualités|prix|météo|prévisions|demain|hier)\b"
|
| 567 |
r"|\b(oggi|adesso|attuale|recente|notizie|prezzo|meteo|previsioni|domani|ieri)\b"
|
|
|
|
| 571 |
r"|\b(dziś|teraz|aktualn\w*|ostatni\w*|wiadomości|cena|pogoda|prognoza|jutro|wczoraj)\b"
|
| 572 |
r"|\b(bugün|şimdi|güncel|haberler|fiyat|hava|tahmini|yarın|dün)\b"
|
| 573 |
r"|\b(اليوم|الآن|الحالي|الأخير|أخبار|سعر|الطقس|توقعات|غدا|أمس)\b"
|
| 574 |
+
# Границы по диапазону письменности, а не \b: матры не входят в \w, и
|
| 575 |
+
# \bहाल\b ловил «हालांकि», \bकल\b — «कल्पना» (аудит 28.10.2026).
|
| 576 |
+
r"|(?<![\u0900-\u097F])(?:आज|अभी|वर्तमान|हाल|समाचार|खबर|कीमत|मौसम|पूर्वानुमान|कल)(?![\u0900-\u097F])"
|
| 577 |
r"|\b(hari ini|sekarang|terbaru|berita|harga|cuaca|prakiraan|besok|kemarin)\b"
|
| 578 |
r"|\b(hari ini|sekarang|terkini|berita|harga|cuaca|ramalan|esok|semalam)\b"
|
| 579 |
r"|\b(hôm nay|bây giờ|hiện tại|mới nhất|tin tức|giá|thời tiết|dự báo|ngày mai|hôm qua)\b"
|
|
|
|
| 581 |
r"|\b(امروز|اکنون|فعلی|اخیر|اخبار|قیمت|هوا|پیشبینی|فردا|دیروز)\b"
|
| 582 |
r"|\b(آج|ابھی|موجودہ|حالیہ|خبریں|قیمت|موسم|پیش گوئی|کل)\b"
|
| 583 |
r"|\b(bugun|hozir|joriy|so'nggi|yangiliklar|narx|ob-havo|prognoz|ertaga|kecha)\b"
|
| 584 |
+
r"|(?<![\u0980-\u09FF])(?:আজ|এখন|বর্তমান|সাম্প্রতিক|সংবাদ|খবর|দাম|আবহাওয়া|পূর্বাভাস|আগামীকাল|গতকাল)(?![\u0980-\u09FF])"
|
| 585 |
r"|\b(ngayon|kasalukuyan|pinakabago|balita|presyo|bukas|kahapon)\b"
|
| 586 |
r"|\b(namuhla|manje|okwamanje|kwakamuva|izindaba|intengo|isimo sezulu|isibikezelo|kusasa|izolo)\b"
|
| 587 |
r"|\b(сёння|зараз|актуальн\w*|апошн\w*|навіны|кошт|цана|надвор'е|прагноз|заўтра|учора)\b"
|
| 588 |
r"|\b(сьогодні|зараз|актуальн\w*|останн\w*|новини|ціна|погода|прогноз|завтра|вчора)\b"
|
| 589 |
r"|\b(бүгін|қазір|ағымдағы|соңғы|жаңалықтар|баға|ауа райы|болжам|ертең|кеше)\b"
|
| 590 |
+
r"|\bзавтра\b|\bвчера\b",
|
| 591 |
re.IGNORECASE,
|
| 592 |
)
|
| 593 |
|
lumen_streaming.py
CHANGED
|
@@ -402,6 +402,7 @@ async def _run_streaming_reply(
|
|
| 402 |
full_text = _scrub_identity_leak(full_text.strip(), source=f"{provider}_stream_final:{model_id}")
|
| 403 |
# "Довывод" остатка: бэкенды одним куском в конце показывали бы "…" до самого финала. Ограничено catchup-лимитом сверху.
|
| 404 |
final_chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
|
|
|
|
| 405 |
if not edits_dead and len(final_chunks) > len(sent_messages):
|
| 406 |
# Инкрементальное разбиение отстаёт на 1000 символов: к финалу
|
| 407 |
# мог появиться новый кусок, и последняя правка затирала ответ
|
|
@@ -428,6 +429,7 @@ async def _run_streaming_reply(
|
|
| 428 |
# Новое сообщение уже содержит полный кусок: catch-up для него не нужен.
|
| 429 |
last_edited_plain = next_chunk
|
| 430 |
reveal_base_ts = time.monotonic()
|
|
|
|
| 431 |
target_full = final_chunks[-1]
|
| 432 |
already_shown_len = len(last_edited_plain) if last_edited_plain and target_full.startswith(last_edited_plain) else 0
|
| 433 |
remaining_len = len(target_full) - already_shown_len
|
|
@@ -474,8 +476,11 @@ async def _run_streaming_reply(
|
|
| 474 |
if edited is None:
|
| 475 |
await bot._tg_call(sent_messages[-1].edit_text, plain_text, parse_mode=None, call_timeout=15.0)
|
| 476 |
else:
|
| 477 |
-
|
| 478 |
-
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
except Exception as exc:
|
| 481 |
if not full_text.strip():
|
|
|
|
| 402 |
full_text = _scrub_identity_leak(full_text.strip(), source=f"{provider}_stream_final:{model_id}")
|
| 403 |
# "Довывод" остатка: бэкенды одним куском в конце показывали бы "…" до самого финала. Ограничено catchup-лимитом сверху.
|
| 404 |
final_chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
|
| 405 |
+
tail_already_final = False
|
| 406 |
if not edits_dead and len(final_chunks) > len(sent_messages):
|
| 407 |
# Инкрементальное разбиение отстаёт на 1000 символов: к финалу
|
| 408 |
# мог появиться новый кусок, и последняя правка затирала ответ
|
|
|
|
| 429 |
# Новое сообщение уже содержит полный кусок: catch-up для него не нужен.
|
| 430 |
last_edited_plain = next_chunk
|
| 431 |
reveal_base_ts = time.monotonic()
|
| 432 |
+
tail_already_final = not edits_dead and last_edited_plain == final_chunks[-1]
|
| 433 |
target_full = final_chunks[-1]
|
| 434 |
already_shown_len = len(last_edited_plain) if last_edited_plain and target_full.startswith(last_edited_plain) else 0
|
| 435 |
remaining_len = len(target_full) - already_shown_len
|
|
|
|
| 476 |
if edited is None:
|
| 477 |
await bot._tg_call(sent_messages[-1].edit_text, plain_text, parse_mode=None, call_timeout=15.0)
|
| 478 |
else:
|
| 479 |
+
if not tail_already_final:
|
| 480 |
+
# HTML->plain fallback — внутри _edit_message_quietly (раньше дублировался здесь вручную).
|
| 481 |
+
# Досланный хвост уже содержит финальный HTML: повторная правка
|
| 482 |
+
# получила бы "not modified" и срезала ссылки/разметку (аудит 28.10.2026).
|
| 483 |
+
await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
|
| 484 |
|
| 485 |
except Exception as exc:
|
| 486 |
if not full_text.strip():
|
tests/test_bot_admin.py
CHANGED
|
@@ -875,6 +875,14 @@ def test_main_flushes_on_graceful_server_exit(monkeypatch, caplog):
|
|
| 875 |
asyncio.run(bot.main())
|
| 876 |
assert "[serve] HTTP server stopped gracefully" in caplog.text
|
| 877 |
assert "chats" in emitted and "quota" in emitted
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
finally:
|
| 879 |
bot.bot, bot.client = orig_bot, orig_client
|
| 880 |
bot._state_ready = orig_ready
|
|
|
|
| 875 |
asyncio.run(bot.main())
|
| 876 |
assert "[serve] HTTP server stopped gracefully" in caplog.text
|
| 877 |
assert "chats" in emitted and "quota" in emitted
|
| 878 |
+
# Частичный сбой стартовой загрузки не отменяет сохранение чатов:
|
| 879 |
+
# иначе последние сообщения терялись при редеплое (ревью 28.10.2026).
|
| 880 |
+
emitted.clear()
|
| 881 |
+
bot._dirty_chat_ids.add(999001)
|
| 882 |
+
lumen_chat_state._quota_dirty = True
|
| 883 |
+
lumen_chat_state._state_load_failed = True
|
| 884 |
+
asyncio.run(bot.main())
|
| 885 |
+
assert "chats" in emitted
|
| 886 |
finally:
|
| 887 |
bot.bot, bot.client = orig_bot, orig_client
|
| 888 |
bot._state_ready = orig_ready
|
tests/test_bot_streaming.py
CHANGED
|
@@ -795,11 +795,14 @@ def test_streaming_final_split_sends_missing_continuation(monkeypatch):
|
|
| 795 |
assert answer and answer.startswith("а" * 1000) and answer.endswith("б" * 200)
|
| 796 |
assert len(fake_bot.sent) == 1
|
| 797 |
first_edit = incoming.sent[0].edits[-1][0]
|
| 798 |
-
|
| 799 |
assert first_edit.startswith("а")
|
| 800 |
-
assert
|
| 801 |
# Оба сообщения вместе покрывают весь ответ, а не только хвост.
|
| 802 |
-
assert len(first_edit) + len(
|
|
|
|
|
|
|
|
|
|
| 803 |
finally:
|
| 804 |
bot.chat_state.pop(chat_id, None)
|
| 805 |
|
|
|
|
| 795 |
assert answer and answer.startswith("а" * 1000) and answer.endswith("б" * 200)
|
| 796 |
assert len(fake_bot.sent) == 1
|
| 797 |
first_edit = incoming.sent[0].edits[-1][0]
|
| 798 |
+
tail_text = fake_bot.sent[0][0]["text"]
|
| 799 |
assert first_edit.startswith("а")
|
| 800 |
+
assert tail_text.endswith("б")
|
| 801 |
# Оба сообщения вместе покрывают весь ответ, а не только хвост.
|
| 802 |
+
assert len(first_edit) + len(tail_text) == len(answer)
|
| 803 |
+
# Досланный хвост уже финальный: повторная правка получила бы
|
| 804 |
+
# "not modified" и заменила HTML на голый текст (аудит 28.10.2026).
|
| 805 |
+
assert fake_bot.sent[0][1].edits == []
|
| 806 |
finally:
|
| 807 |
bot.chat_state.pop(chat_id, None)
|
| 808 |
|
tests/test_lumen_router_config.py
CHANGED
|
@@ -157,6 +157,15 @@ def test_looks_like_freshness_query_multilingual_ignores_common_words():
|
|
| 157 |
assert lumen_router_config._looks_like_freshness_query("son derece güzel") is False
|
| 158 |
assert lumen_router_config._looks_like_freshness_query("Sa panahong ito") is False
|
| 159 |
assert lumen_router_config._looks_like_freshness_query("Guten Morgen") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
|
| 162 |
def test_looks_like_search_request_detects_explicit_tool_asks():
|
|
|
|
| 157 |
assert lumen_router_config._looks_like_freshness_query("son derece güzel") is False
|
| 158 |
assert lumen_router_config._looks_like_freshness_query("Sa panahong ito") is False
|
| 159 |
assert lumen_router_config._looks_like_freshness_query("Guten Morgen") is False
|
| 160 |
+
# Ревью 28.10.2026: без границ «завтра» ловил «завтрак», а «हाल/कल»
|
| 161 |
+
# срабатывали внутри हालांकि/कल्पना (матры не входят в \w).
|
| 162 |
+
assert lumen_router_config._looks_like_freshness_query("что приготовить на завтрак") is False
|
| 163 |
+
assert lumen_router_config._looks_like_freshness_query("позавтракать вместе") is False
|
| 164 |
+
assert lumen_router_config._looks_like_freshness_query("the actual reason is simple") is False
|
| 165 |
+
assert lumen_router_config._looks_like_freshness_query("हालांकि यह ठीक है") is False
|
| 166 |
+
assert lumen_router_config._looks_like_freshness_query("कल्पना कीजिए") is False
|
| 167 |
+
assert lumen_router_config._looks_like_freshness_query("আজাদি") is False
|
| 168 |
+
assert lumen_router_config._looks_like_freshness_query("что будет завтра") is True
|
| 169 |
|
| 170 |
|
| 171 |
def test_looks_like_search_request_detects_explicit_tool_asks():
|