Spaces:
Running
Running
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 +10 -4
- lumen_message_core.py +7 -0
- lumen_message_parse.py +1 -1
- lumen_router_config.py +7 -5
- lumen_routes.py +4 -4
- lumen_tts.py +1 -1
- tests/test_bot_commands.py +5 -0
- tests/test_bot_routes.py +15 -0
- tests/test_bot_state.py +25 -0
- tests/test_lumen_router_config.py +14 -0
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 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "
|
| 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 |
-
|
|
|
|
| 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)|
|
| 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 |
-
|
| 446 |
-
r"|
|
|
|
|
| 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
|
| 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)
|