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 CHANGED
@@ -1778,21 +1778,19 @@ async def main() -> None:
1778
  # Флаги — канонически в lumen_chat_state (P2): множества общие объектом,
1779
  # bool-флаги читаем из модуля, т.к. bot-привязки после выноса stale.
1780
  # Синхронная запись в потоке: loop уже дренирует задачи, висеть нельзя.
1781
- if lumen_chat_state._state_load_failed:
1782
- # Стартовая загрузка не удалась: не затираем удалённые данные
1783
- # (аудит 28.10.2026).
1784
- log.warning('[state] Startup load failed: skipping the final flush to keep remote data intact.')
1785
- else:
1786
- if _dirty_chat_ids or lumen_chat_state._index_dirty or _pending_chat_deletions:
1787
- try:
1788
- await asyncio.wait_for(asyncio.to_thread(_flush_state_now), timeout=15.0)
1789
- except asyncio.TimeoutError:
1790
- log.warning('[state] Final flush exceeded 15s; abandoning the remainder.')
1791
- if lumen_chat_state._quota_dirty:
1792
- try:
1793
- await asyncio.wait_for(asyncio.to_thread(save_global_quota), timeout=10.0)
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
- # Прокси TikWM требует секрет: без него честный 401 вместо живого API.
267
- "tikwm": bot.TIKWM_API_BASE_URL or "https://www.tikwm.com",
 
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 bot.TIKWM_API_BASE_URL:
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
- # а не прямой хост, который там всегда 403 (аудит 28.10.2026).
769
- tikwm_headers = proxy_headers if bot.TIKWM_API_BASE_URL else None
 
 
 
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, bot.TIKWM_API_BASE_URL or "https://www.tikwm.com",
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
- r"|\b(hoy|ahora|actual|reciente|noticias|precio|clima|mañana|ayer)\b"
 
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
- r"|(आज|अभी|वर्तमान|हाल|समाचार|खबर|कीमत|मौसम|पूर्वानुमान|कल)"
 
 
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
- # HTML->plain fallback — внутри _edit_message_quietly (раньше дублировался здесь вручную).
478
- await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
 
 
 
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
- tail_edit = fake_bot.sent[0][1].edits[-1][0]
799
  assert first_edit.startswith("а")
800
- assert tail_edit.endswith("б")
801
  # Оба сообщения вместе покрывают весь ответ, а не только хвост.
802
- assert len(first_edit) + len(tail_edit) == len(answer)
 
 
 
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():