SilverElixir commited on
Commit
f95285c
·
1 Parent(s): dd4c939

Luna audit round 2: review fixes (regex bounds, mixed albums, redaction guards)

Browse files
lumen_limits.py CHANGED
@@ -34,10 +34,16 @@ def _check_and_register_rate_limit(user_id: int | None) -> bool:
34
  if user_id not in user_rate_limits and len(user_rate_limits) >= MAX_RATE_LIMIT_KEYS:
35
  _cleanup_rate_limit_dict()
36
  # Чистка сносит только протухших: при флуде свежими ID словарь рос бы без потолка.
37
- # Добиваем жёстко — самых давно молчавших (найдено внешним аудитом).
38
- while len(user_rate_limits) >= MAX_RATE_LIMIT_KEYS:
39
- oldest = min(user_rate_limits, key=lambda uid: user_rate_limits[uid][-1] if user_rate_limits[uid] else 0)
40
- user_rate_limits.pop(oldest, None)
 
 
 
 
 
 
41
  timestamps = user_rate_limits.setdefault(user_id, [])
42
  while timestamps and now - timestamps[0] > RATE_LIMIT_WINDOW_SEC:
43
  timestamps.pop(0)
 
34
  if user_id not in user_rate_limits and len(user_rate_limits) >= MAX_RATE_LIMIT_KEYS:
35
  _cleanup_rate_limit_dict()
36
  # Чистка сносит только протухших: при флуде свежими ID словарь рос бы без потолка.
37
+ # Добиваем жёстко — самых давно молчавших (найдено внешним аудитом). Сносим с запасом
38
+ # (до 90% потолка), чтобы не сканировать весь словарь на каждый новый ID (ревью ветки).
39
+ if len(user_rate_limits) >= MAX_RATE_LIMIT_KEYS:
40
+ ordered = sorted(
41
+ user_rate_limits,
42
+ key=lambda uid: user_rate_limits[uid][-1] if user_rate_limits[uid] else 0,
43
+ )
44
+ drop = len(ordered) - int(MAX_RATE_LIMIT_KEYS * 0.9) + 1
45
+ for uid in ordered[:drop]:
46
+ user_rate_limits.pop(uid, None)
47
  timestamps = user_rate_limits.setdefault(user_id, [])
48
  while timestamps and now - timestamps[0] > RATE_LIMIT_WINDOW_SEC:
49
  timestamps.pop(0)
lumen_message_core.py CHANGED
@@ -305,6 +305,13 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
305
  pass
306
 
307
  media_mime = media_tuple[1] if media_tuple else None
 
 
 
 
 
 
 
308
  is_heavy = _looks_like_heavy_query(clean_prompt)
309
  needs_freshness = _looks_like_freshness_query(clean_prompt)
310
  route = _build_route(
 
305
  pass
306
 
307
  media_mime = media_tuple[1] if media_tuple else None
308
+ # Смешанный альбом (фото + видео-слайды): достаточно одного видео — весь набор едет
309
+ # в Gemini-ветку, иначе видео-слайды молча терялись (ревью ветки: смотрели только media[0]).
310
+ if media_mime and media_mime.startswith("image/") and extra_media:
311
+ for _, extra_mime in extra_media:
312
+ if not (extra_mime or "").startswith("image/"):
313
+ media_mime = extra_mime
314
+ break
315
  is_heavy = _looks_like_heavy_query(clean_prompt)
316
  needs_freshness = _looks_like_freshness_query(clean_prompt)
317
  route = _build_route(
lumen_message_parse.py CHANGED
@@ -50,7 +50,7 @@ DRAW_TRIGGER_PREFIXES = [
50
  "сгенери картинку", "сгенери изображение", "можешь нарисовать", "можешь нарисовать мне",
51
  # EN: /start обещает "draw a cat" — чиним (найдено внешним аудитом: был только русский).
52
  "generate an image", "generate me an image", "create an image", "draw a picture",
53
- "draw me", "draw an", "draw a", "draw some", "paint me", "paint", "sketch",
54
  ]
55
  # "хочу картинку" не триггер: путается с показом/редактурой. Длинные фразы выше коротких, иначе вернётся первое совпадение.
56
  TTS_TRIGGER_PREFIXES = [
 
50
  "сгенери картинку", "сгенери изображение", "можешь нарисовать", "можешь нарисовать мне",
51
  # EN: /start обещает "draw a cat" — чиним (найдено внешним аудитом: был только русский).
52
  "generate an image", "generate me an image", "create an image", "draw a picture",
53
+ "draw me", "draw an", "draw a", "paint me", "paint", "sketch",
54
  ]
55
  # "хочу картинку" не триггер: путается с показом/редактурой. Длинные фразы выше коротких, иначе вернётся первое совпадение.
56
  TTS_TRIGGER_PREFIXES = [
lumen_router_config.py CHANGED
@@ -403,11 +403,12 @@ _HEAVY_QUERY_RE = re.compile(
403
  r"|многошагов\w*|пошагов\w*\s+(инструкц\w*|план\w*)"
404
  r"|архитектур\w*|алгоритм\w*"
405
  # EN-набор (найдено внешним аудитом: heavy-детект был почти весь русский).
406
- r"|write\s+(a\s+|an\s+|the\s+)?(\w+\s+)?(code|function|script|program|class|sql|regex|test|parser|bot|website|app)\b"
 
407
  r"|generate\s+code|fix\s+(this\s+|that\s+)?(code|bug|error|issue)\b|refactor\w*|optimiz\w*|debug"
408
  r"|explain\s+(code|error)|code\s+review|algorithm|architecture"
409
  r"|write\s+(an?\s+)?(essay|article|report|paper|thesis|cv|resume)\b"
410
- r"|compar(e|ison)|prove|proof|solve|equation",
411
  re.IGNORECASE,
412
  )
413
 
@@ -441,9 +442,10 @@ _FRESHNESS_QUERY_RE = re.compile(
441
  r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
442
  r"|\b202[6-9]\b"
443
  # EN-набор (найдено внешним аудитом: детект был только русским при DEFAULT_LANG=en
444
- # и 25 языках; ложные срабатывания так же дёшевы).
445
- r"|now|today|current\w*|latest|recent\w*|breaking"
446
- r"|news|weather|forecast|price\w*|cost\w*|how\s+much|exchange|score\w*|schedule"
 
447
  r"|who\s+is\s+(now|currently|the\s+(president|ceo|prime\s+minister|mayor))"
448
  r"|best\s+(to\s+buy|buy)|should\s+i\s+buy|worth\s+buying",
449
  re.IGNORECASE,
 
403
  r"|многошагов\w*|пошагов\w*\s+(инструкц\w*|план\w*)"
404
  r"|архитектур\w*|алгоритм\w*"
405
  # EN-набор (найдено внешним аудитом: heavy-детект был почти весь русский).
406
+ # Границы слов обязательны: голый prove ловил improve, solve — resolve (ревью ветки).
407
+ r"|write\s+(a\s+|an\s+|the\s+)?(\w+\s+)?(code|function\w*|script\w*|program\w*|class\w*|sql|regex|test\w*|parser\w*|bot|website\w*|app\w*)\b"
408
  r"|generate\s+code|fix\s+(this\s+|that\s+)?(code|bug|error|issue)\b|refactor\w*|optimiz\w*|debug"
409
  r"|explain\s+(code|error)|code\s+review|algorithm|architecture"
410
  r"|write\s+(an?\s+)?(essay|article|report|paper|thesis|cv|resume)\b"
411
+ r"|compar(e|ison)|\bprove\b|\bproof\b|\bsolve\b|equation",
412
  re.IGNORECASE,
413
  )
414
 
 
442
  r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
443
  r"|\b202[6-9]\b"
444
  # EN-набор (найдено внешним аудитом: детект был только русским при DEFAULT_LANG=en
445
+ # и 25 языках; ложные срабатывания так же дёшевы). Границы слов обязательны:
446
+ # голый now ловил know/snow (ревью ветки).
447
+ r"|\bnow\b|\btoday\b|current\w*|latest|recent\w*|\bbreaking\b"
448
+ r"|\bnews\b|weather|forecast|price\w*|\bcost\w*|how\s+much|exchange|\bscore\w*|schedule"
449
  r"|who\s+is\s+(now|currently|the\s+(president|ceo|prime\s+minister|mayor))"
450
  r"|best\s+(to\s+buy|buy)|should\s+i\s+buy|worth\s+buying",
451
  re.IGNORECASE,
lumen_routes.py CHANGED
@@ -84,7 +84,7 @@ async def _groq_request(path: str, method: str = "GET", *, json_body: dict | Non
84
  raise
85
  except Exception as exc:
86
  exc_str = str(exc) or repr(exc) or exc.__class__.__name__
87
- if bot.GROQ_API_KEY:
88
  exc_str = exc_str.replace(bot.GROQ_API_KEY, "<KEY>")
89
  raise bot.GroqAPIError(f"Groq network error: {exc_str}") from exc
90
 
@@ -118,7 +118,7 @@ async def _or_request(path: str, method: str = "GET", *, json_body: dict | None
118
  # str(exc) у таймаутов часто пуст — берём repr/имя класса, иначе в логах пустая строка.
119
  exc_str = str(exc) or repr(exc) or exc.__class__.__name__
120
  # Вычищаем OPENROUTER_API_KEY из текста ошибок (defense-in-depth: обычно ключ только в заголовке, но прокси может процитировать заголовки).
121
- if bot.OPENROUTER_API_KEY:
122
  exc_str = exc_str.replace(bot.OPENROUTER_API_KEY, "<KEY>")
123
  raise bot.OpenRouterAPIError(f"OpenRouter network error: {exc_str}") from exc
124
 
@@ -346,7 +346,7 @@ async def ask_openrouter_multimodal(
346
  for raw_bytes, mime in media_items[:10]:
347
  # Видео-слайды альбомов сюда не ходят (OpenRouter принимает только картинки) —
348
  # их забирает Gemini-ветка; раньше они просто молча отваливались вместе со 2-9 фото.
349
- if not mime.startswith("image/"):
350
  continue
351
  b64 = base64.b64encode(raw_bytes).decode("utf-8")
352
  parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
@@ -776,7 +776,7 @@ async def _run_route(
776
  tried_stream_model, tried_stream_provider = head_model, "groq"
777
  reusable_placeholder = placeholder
778
 
779
- is_video_or_audio = bool(media) and not media[0][1].startswith("image/")
780
  last_exc: Exception | None = None
781
  for provider in provider_order:
782
  ids = list(groups.get(provider) or [])
 
84
  raise
85
  except Exception as exc:
86
  exc_str = str(exc) or repr(exc) or exc.__class__.__name__
87
+ if bot.GROQ_API_KEY and len(bot.GROQ_API_KEY) > 8:
88
  exc_str = exc_str.replace(bot.GROQ_API_KEY, "<KEY>")
89
  raise bot.GroqAPIError(f"Groq network error: {exc_str}") from exc
90
 
 
118
  # str(exc) у таймаутов часто пуст — берём repr/имя класса, иначе в логах пустая строка.
119
  exc_str = str(exc) or repr(exc) or exc.__class__.__name__
120
  # Вычищаем OPENROUTER_API_KEY из текста ошибок (defense-in-depth: обычно ключ только в заголовке, но прокси может процитировать заголовки).
121
+ if bot.OPENROUTER_API_KEY and len(bot.OPENROUTER_API_KEY) > 8:
122
  exc_str = exc_str.replace(bot.OPENROUTER_API_KEY, "<KEY>")
123
  raise bot.OpenRouterAPIError(f"OpenRouter network error: {exc_str}") from exc
124
 
 
346
  for raw_bytes, mime in media_items[:10]:
347
  # Видео-слайды альбомов сюда не ходят (OpenRouter принимает только картинки) —
348
  # их забирает Gemini-ветка; раньше они просто молча отваливались вместе со 2-9 фото.
349
+ if not (mime or "").startswith("image/"):
350
  continue
351
  b64 = base64.b64encode(raw_bytes).decode("utf-8")
352
  parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
 
776
  tried_stream_model, tried_stream_provider = head_model, "groq"
777
  reusable_placeholder = placeholder
778
 
779
+ is_video_or_audio = bool(media) and any(not (m[1] or "").startswith("image/") for m in media)
780
  last_exc: Exception | None = None
781
  for provider in provider_order:
782
  ids = list(groups.get(provider) or [])
lumen_tts.py CHANGED
@@ -86,7 +86,7 @@ async def _fish_audio_tts_bytes(
86
  except Exception as exc:
87
  # Ключ из текста ошибок вычищаем (тот же defense-in-depth, что у _or_request/_groq_request).
88
  exc_str = str(exc) or repr(exc) or exc.__class__.__name__
89
- if api_key:
90
  exc_str = exc_str.replace(api_key, "<KEY>")
91
  log.warning("[tts] Fish Audio request failed, falling back to Gemini TTS: %s", exc_str)
92
  return None
 
86
  except Exception as exc:
87
  # Ключ из текста ошибок вычищаем (тот же defense-in-depth, что у _or_request/_groq_request).
88
  exc_str = str(exc) or repr(exc) or exc.__class__.__name__
89
+ if api_key and len(api_key) > 8:
90
  exc_str = exc_str.replace(api_key, "<KEY>")
91
  log.warning("[tts] Fish Audio request failed, falling back to Gemini TTS: %s", exc_str)
92
  return None
tests/test_bot_commands.py CHANGED
@@ -219,6 +219,11 @@ def test_match_trigger_prefix_english_triggers():
219
  assert bot._match_trigger_prefix("how are you", bot.TTS_TRIGGER_PREFIXES) is None
220
 
221
 
 
 
 
 
 
222
  def test_tts_trigger_this_with_reply_voices_replied_message(rate_guard_setup):
223
  # Регрессия (сентябрь 2026): "озвучь это" в ответ на сообщение озвучивало
224
  # само слово "это" — остаток после триггера считался содержанием.
 
219
  assert bot._match_trigger_prefix("how are you", bot.TTS_TRIGGER_PREFIXES) is None
220
 
221
 
222
+ def test_match_trigger_prefix_rejects_draw_idiom():
223
+ # Ревью ветки: "draw some" ловил идиому "draw conclusions".
224
+ assert bot._match_trigger_prefix("draw some conclusions here", bot.DRAW_TRIGGER_PREFIXES) is None
225
+
226
+
227
  def test_tts_trigger_this_with_reply_voices_replied_message(rate_guard_setup):
228
  # Регрессия (сентябрь 2026): "озвучь это" в ответ на сообщение озвучивало
229
  # само слово "это" — остаток после триггера считался содержанием.
tests/test_bot_routes.py CHANGED
@@ -832,6 +832,21 @@ def test_ask_openrouter_multimodal_skips_video_slides_for_gemini():
832
  bot.chat_state.pop(chat_id, None)
833
 
834
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835
  def test_route_error_reply_text_youtube_takes_priority_over_exception_type():
836
  exc = bot.OpenRouterAPIError("boom", status_code=500)
837
  text = bot._route_error_reply_text(exc, "gemini-3.6-flash", youtube_url_to_analyze="https://youtu.be/x")
 
832
  bot.chat_state.pop(chat_id, None)
833
 
834
 
835
+ def test_ask_openrouter_multimodal_rejects_video_only_album():
836
+ # Только видео без картинок — нечего слать в OpenRouter: ошибка уводит маршрут в Gemini.
837
+ chat_id = 999413
838
+ original_key = bot.OPENROUTER_API_KEY
839
+ bot.OPENROUTER_API_KEY = "fake-key"
840
+ try:
841
+ with pytest.raises(RuntimeError):
842
+ asyncio.run(bot.ask_openrouter_multimodal(
843
+ chat_id, "что это?", [(b"vid", "video/mp4")], "a.mp4", model_chain=["m"],
844
+ ))
845
+ finally:
846
+ bot.OPENROUTER_API_KEY = original_key
847
+ bot.chat_state.pop(chat_id, None)
848
+
849
+
850
  def test_route_error_reply_text_youtube_takes_priority_over_exception_type():
851
  exc = bot.OpenRouterAPIError("boom", status_code=500)
852
  text = bot._route_error_reply_text(exc, "gemini-3.6-flash", youtube_url_to_analyze="https://youtu.be/x")
tests/test_bot_state.py CHANGED
@@ -737,6 +737,31 @@ def test_voice_message_falls_back_to_gemini_audio_on_transcribe_failure(rate_gua
737
  bot.chat_state.pop(123, None)
738
 
739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  def test_unfetchable_attachment_gets_honest_error_not_silence(rate_guard_setup, monkeypatch):
741
  # Вложение есть, а скачать не вышло — честная ошибка вместо "Слушаю" в пустоту (прод 23.09.2026).
742
  message = rate_guard_setup()
 
737
  bot.chat_state.pop(123, None)
738
 
739
 
740
+ def test_mixed_album_with_video_slide_routes_to_gemini(rate_guard_setup, monkeypatch):
741
+ # Ревью ветки: смотрели только первое вложение — фото+видео уходило image-маршрутом и видео терялось.
742
+ message = rate_guard_setup()
743
+ message.text = ""
744
+ captured = {}
745
+
746
+ async def fake_resolve(message, state, clean_prompt, *, is_private):
747
+ return None, "", "photo.jpg", (b"pic", "image/jpeg")
748
+
749
+ async def fake_run_route(chat_id, ai_prompt, route, message, **kwargs):
750
+ captured["route"] = route
751
+ captured["media"] = kwargs.get("media")
752
+ return "ok", False
753
+
754
+ monkeypatch.setattr(bot, "_resolve_incoming_media", fake_resolve)
755
+ monkeypatch.setattr(bot, "_run_route", fake_run_route)
756
+ monkeypatch.setattr(bot, "_safe_reply", AsyncMock())
757
+ asyncio.run(bot._handle_message_core(
758
+ message, extra_media=[(b"vid", "video/mp4")],
759
+ ))
760
+ assert captured["route"][0][0] == "gemini"
761
+ assert len(captured["media"]) == 2
762
+ bot.chat_state.pop(123, None)
763
+
764
+
765
  def test_unfetchable_attachment_gets_honest_error_not_silence(rate_guard_setup, monkeypatch):
766
  # Вложение есть, а скачать не вышло — честная ошибка вместо "Слушаю" в пустоту (прод 23.09.2026).
767
  message = rate_guard_setup()
tests/test_lumen_router_config.py CHANGED
@@ -101,6 +101,13 @@ def test_looks_like_freshness_query_detects_english():
101
  assert lumen_router_config._looks_like_freshness_query("just chatting about life") is False
102
 
103
 
 
 
 
 
 
 
 
104
  def test_looks_like_heavy_query_detects_english():
105
  # Внешний аудит: "write a Python parser" пролетал в light-ветку.
106
  assert lumen_router_config._looks_like_heavy_query("write a Python parser for csv") is True
@@ -109,6 +116,13 @@ def test_looks_like_heavy_query_detects_english():
109
  assert lumen_router_config._looks_like_heavy_query("just saying hello") is False
110
 
111
 
 
 
 
 
 
 
 
112
  def test_build_route_youtube_link_forces_gemini_only():
113
  route = lumen_router_config._build_route(needs_youtube=True, needs_website=False, media_mime=None, is_heavy=False, needs_freshness=False)
114
  assert all(p == "gemini" for p, _ in route)
 
101
  assert lumen_router_config._looks_like_freshness_query("just chatting about life") is False
102
 
103
 
104
+ def test_looks_like_freshness_query_ignores_substrings():
105
+ # Ревью ветки: голый now ловил know/snow — высокочастотные ложняки на обычной болтовне.
106
+ assert lumen_router_config._looks_like_freshness_query("I know kung fu") is False
107
+ assert lumen_router_config._looks_like_freshness_query("snow here") is False
108
+ assert lumen_router_config._looks_like_freshness_query("groundbreaking discovery") is False
109
+
110
+
111
  def test_looks_like_heavy_query_detects_english():
112
  # Внешний аудит: "write a Python parser" пролетал в light-ветку.
113
  assert lumen_router_config._looks_like_heavy_query("write a Python parser for csv") is True
 
116
  assert lumen_router_config._looks_like_heavy_query("just saying hello") is False
117
 
118
 
119
+ def test_looks_like_heavy_query_ignores_substrings_and_handles_plurals():
120
+ # Ревью ветки: prove ловил improve, solve — resolve; "write two scripts" пролетал мимо.
121
+ assert lumen_router_config._looks_like_heavy_query("please improve my text") is False
122
+ assert lumen_router_config._looks_like_heavy_query("lets resolve this conflict") is False
123
+ assert lumen_router_config._looks_like_heavy_query("write two scripts for backup") is True
124
+
125
+
126
  def test_build_route_youtube_link_forces_gemini_only():
127
  route = lumen_router_config._build_route(needs_youtube=True, needs_website=False, media_mime=None, is_heavy=False, needs_freshness=False)
128
  assert all(p == "gemini" for p, _ in route)