Spaces:
Running
Running
SilverElixir commited on
Commit ·
9bccd1d
1
Parent(s): fe3cf23
Ideal routing: voice transcribed into scenario routing, OR qwen newcomer in light chain
Browse files- bot.py +4 -0
- docs/ARCHITECTURE.md +1 -1
- docs/ENVIRONMENT.md +2 -1
- lumen_message_core.py +10 -0
- lumen_router_config.py +4 -0
- lumen_routes.py +40 -0
- tests/test_bot_routes.py +62 -0
- tests/test_bot_state.py +53 -0
bot.py
CHANGED
|
@@ -245,6 +245,8 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
| 245 |
# Groq — прямой провайдер лёгкого текста (калибровка 21.09.2026): 1000 запросов/день против 50 у OpenRouter.
|
| 246 |
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
|
| 247 |
GROQ_BASE_URL = "https://api.groq.com/openai/v1"
|
|
|
|
|
|
|
| 248 |
# Раньше у OpenRouter был свой отдельный лимит истории (30), меньший, чем у Gemini
|
| 249 |
# (100) — при переключении провайдера (/provider или /model) ощущалось резкое
|
| 250 |
# "обнуление" контекста разговора. Теперь история ОБЩАЯ (см. state["history"] в
|
|
@@ -713,6 +715,7 @@ __all__ = [
|
|
| 713 |
"ask_openrouter_text",
|
| 714 |
"ask_openrouter_multimodal",
|
| 715 |
"ask_groq_text",
|
|
|
|
| 716 |
"_gemini_history_contents",
|
| 717 |
"_build_gemma_identity_contents",
|
| 718 |
"_build_gemini_call_config",
|
|
@@ -945,6 +948,7 @@ from lumen_routes import (
|
|
| 945 |
ask_openrouter_text,
|
| 946 |
ask_openrouter_multimodal,
|
| 947 |
ask_groq_text,
|
|
|
|
| 948 |
_gemini_history_contents,
|
| 949 |
_build_gemma_identity_contents,
|
| 950 |
_build_gemini_call_config,
|
|
|
|
| 245 |
# Groq — прямой провайдер лёгкого текста (калибровка 21.09.2026): 1000 запросов/день против 50 у OpenRouter.
|
| 246 |
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
|
| 247 |
GROQ_BASE_URL = "https://api.groq.com/openai/v1"
|
| 248 |
+
# Голосовые длиннее не транскрибируем (Whisper API всё равно режет 25 МБ) — такие идут прежним путём через Gemini-аудио.
|
| 249 |
+
VOICE_TRANSCRIBE_MAX_BYTES = int(os.getenv("VOICE_TRANSCRIBE_MAX_BYTES", str(10 * 1024 * 1024)))
|
| 250 |
# Раньше у OpenRouter был свой отдельный лимит истории (30), меньший, чем у Gemini
|
| 251 |
# (100) — при переключении провайдера (/provider или /model) ощущалось резкое
|
| 252 |
# "обнуление" контекста разговора. Теперь история ОБЩАЯ (см. state["history"] в
|
|
|
|
| 715 |
"ask_openrouter_text",
|
| 716 |
"ask_openrouter_multimodal",
|
| 717 |
"ask_groq_text",
|
| 718 |
+
"_transcribe_audio",
|
| 719 |
"_gemini_history_contents",
|
| 720 |
"_build_gemma_identity_contents",
|
| 721 |
"_build_gemini_call_config",
|
|
|
|
| 948 |
ask_openrouter_text,
|
| 949 |
ask_openrouter_multimodal,
|
| 950 |
ask_groq_text,
|
| 951 |
+
_transcribe_audio,
|
| 952 |
_gemini_history_contents,
|
| 953 |
_build_gemma_identity_contents,
|
| 954 |
_build_gemini_call_config,
|
docs/ARCHITECTURE.md
CHANGED
|
@@ -15,7 +15,7 @@ This document goes one level deeper than the main [README](../README.md) into ho
|
|
| 15 |
Routing is fully automatic: the router builds a fresh candidate list for every message:
|
| 16 |
|
| 17 |
- **A YouTube or website link** → Gemini only. It's the only provider that can read page content (`url_context`) or analyze a video by URL.
|
| 18 |
-
- **A video or audio attachment** → Gemini only
|
| 19 |
- **An image attachment, no live-info need** → free OpenRouter vision models first, Gemini as a reserve.
|
| 20 |
- **Needs current information** (a lightweight keyword heuristic: "now," "today," "price," "who is currently...") → Gemini, prioritizing the models with a real search-grounding quota, then OpenRouter as a reserve, Groq last (knowledge-only answer if both are down).
|
| 21 |
- **Plain text, no attachments, no freshness need** (the most common case) → Groq first (1000 free requests/day: Qwen, then gpt-oss), then OpenRouter. Heavier requests (code, multi-step reasoning, caught by another lightweight heuristic) get routed to the stronger free OpenRouter models first.
|
|
|
|
| 15 |
Routing is fully automatic: the router builds a fresh candidate list for every message:
|
| 16 |
|
| 17 |
- **A YouTube or website link** → Gemini only. It's the only provider that can read page content (`url_context`) or analyze a video by URL.
|
| 18 |
+
- **A video or audio attachment** → video goes to Gemini only (OpenRouter's multimodal models only accept images as base64). Voice/audio is first transcribed cheaply (Groq Whisper) and the text joins the normal routing below; if transcription fails or is unavailable, the audio goes to Gemini as before.
|
| 19 |
- **An image attachment, no live-info need** → free OpenRouter vision models first, Gemini as a reserve.
|
| 20 |
- **Needs current information** (a lightweight keyword heuristic: "now," "today," "price," "who is currently...") → Gemini, prioritizing the models with a real search-grounding quota, then OpenRouter as a reserve, Groq last (knowledge-only answer if both are down).
|
| 21 |
- **Plain text, no attachments, no freshness need** (the most common case) → Groq first (1000 free requests/day: Qwen, then gpt-oss), then OpenRouter. Heavier requests (code, multi-step reasoning, caught by another lightweight heuristic) get routed to the stronger free OpenRouter models first.
|
docs/ENVIRONMENT.md
CHANGED
|
@@ -48,10 +48,11 @@ Before publishing, configure the same `LUMEN_PROXY_SECRET` on HF and every Deno
|
|
| 48 |
| Variable | Default | Purpose |
|
| 49 |
|---|---|---|
|
| 50 |
| `OPENROUTER_API_KEY` / `OPENROUTER_KEY` | — | OpenRouter API key. Without it, the router sends everything to Gemini, which burns through its much smaller quota fast. |
|
| 51 |
-
| `GROQ_API_KEY` | — | Groq API key (free plan, no card: 1000 req/day). Heads plain-text routes; without it OpenRouter heads them as before. |
|
| 52 |
| `OPENROUTER_HTTP_REFERER` | `https://t.me/{BOT_USERNAME}` | `HTTP-Referer` header sent with OpenRouter requests. |
|
| 53 |
| `OPENROUTER_TITLE` | `BOT_USERNAME` | App title header sent with OpenRouter requests. |
|
| 54 |
| `POLLINATIONS_IMAGE_MODEL` | `flux` | Default image-generation model (used only if the prompt doesn't match a more specific style). Renamed from `HF_IMAGE_MODEL` (Sept 2026) — the backend is Pollinations.ai, not Hugging Face; the old name is no longer read. |
|
|
|
|
| 55 |
|
| 56 |
## Timeouts, rate limiting & routing budgets
|
| 57 |
|
|
|
|
| 48 |
| Variable | Default | Purpose |
|
| 49 |
|---|---|---|
|
| 50 |
| `OPENROUTER_API_KEY` / `OPENROUTER_KEY` | — | OpenRouter API key. Without it, the router sends everything to Gemini, which burns through its much smaller quota fast. |
|
| 51 |
+
| `GROQ_API_KEY` | — | Groq API key (free plan, no card: 1000 req/day). Heads plain-text routes; without it OpenRouter heads them as before. Also powers voice transcription (Whisper). |
|
| 52 |
| `OPENROUTER_HTTP_REFERER` | `https://t.me/{BOT_USERNAME}` | `HTTP-Referer` header sent with OpenRouter requests. |
|
| 53 |
| `OPENROUTER_TITLE` | `BOT_USERNAME` | App title header sent with OpenRouter requests. |
|
| 54 |
| `POLLINATIONS_IMAGE_MODEL` | `flux` | Default image-generation model (used only if the prompt doesn't match a more specific style). Renamed from `HF_IMAGE_MODEL` (Sept 2026) — the backend is Pollinations.ai, not Hugging Face; the old name is no longer read. |
|
| 55 |
+
| `VOICE_TRANSCRIBE_MAX_BYTES` | `10485760` (10 MB) | Voice/audio attachments larger than this skip Whisper transcription and go straight to Gemini audio, as before. |
|
| 56 |
|
| 57 |
## Timeouts, rate limiting & routing budgets
|
| 58 |
|
lumen_message_core.py
CHANGED
|
@@ -262,6 +262,16 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
|
|
| 262 |
message, state, clean_prompt, is_private=is_private,
|
| 263 |
)
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
if media_tuple and not clean_prompt:
|
| 266 |
clean_prompt = _ensure_prompt_text(None, media_tuple[1])
|
| 267 |
if youtube_url_to_analyze and not clean_prompt:
|
|
|
|
| 262 |
message, state, clean_prompt, is_private=is_private,
|
| 263 |
)
|
| 264 |
|
| 265 |
+
# Голос/аудио: сначала дешёвая транскрибация — дальше текст идёт общим роутингом по
|
| 266 |
+
# сценарию (тяжесть/свежесть определяются по сказанному). Не вышло — падает в прежний
|
| 267 |
+
# путь: аудио напрямую в Gemini (см. is_video_or_audio_media в роутере).
|
| 268 |
+
if media_tuple and media_tuple[1].startswith("audio/"):
|
| 269 |
+
transcript = await bot._transcribe_audio(media_tuple[0], message.chat.id)
|
| 270 |
+
if transcript:
|
| 271 |
+
marked = "[Расшифровка голосового]: " + transcript
|
| 272 |
+
clean_prompt = (clean_prompt + "\n" + marked).strip() if clean_prompt else marked
|
| 273 |
+
media_tuple = None
|
| 274 |
+
|
| 275 |
if media_tuple and not clean_prompt:
|
| 276 |
clean_prompt = _ensure_prompt_text(None, media_tuple[1])
|
| 277 |
if youtube_url_to_analyze and not clean_prompt:
|
lumen_router_config.py
CHANGED
|
@@ -180,6 +180,7 @@ _KNOWN_MODEL_IDS_FOR_LEAK_DETECTION: list[str] = [
|
|
| 180 |
"inclusionai/ling-3.0-flash-fin:free",
|
| 181 |
"inclusionai/ling-3.0-flash-vl:free",
|
| 182 |
"liquid/lfm-2.5-2.6b:free",
|
|
|
|
| 183 |
"gemini-3.8-flash",
|
| 184 |
"openrouter/free",
|
| 185 |
# Groq-ID без :free-суффикса — те же семейства, что выше через OpenRouter; дословно в ответе им тоже не место.
|
|
@@ -320,6 +321,9 @@ _OR_LIGHT_ORDER: list[str] = [
|
|
| 320 |
"inclusionai/ling-3.0-flash-sante:free",
|
| 321 |
"inclusionai/ling-3.0-flash-fin:free",
|
| 322 |
"liquid/lfm-2.5-2.6b:free",
|
|
|
|
|
|
|
|
|
|
| 323 |
"openrouter/free",
|
| 324 |
]
|
| 325 |
|
|
|
|
| 180 |
"inclusionai/ling-3.0-flash-fin:free",
|
| 181 |
"inclusionai/ling-3.0-flash-vl:free",
|
| 182 |
"liquid/lfm-2.5-2.6b:free",
|
| 183 |
+
"qwen/qwen3.8-27b:free",
|
| 184 |
"gemini-3.8-flash",
|
| 185 |
"openrouter/free",
|
| 186 |
# Groq-ID без :free-суффикса — те же семейства, что выше через OpenRouter; дословно в ответе им тоже не место.
|
|
|
|
| 321 |
"inclusionai/ling-3.0-flash-sante:free",
|
| 322 |
"inclusionai/ling-3.0-flash-fin:free",
|
| 323 |
"liquid/lfm-2.5-2.6b:free",
|
| 324 |
+
# Аудит 21.09.2026 (живой каталог): qwen3.8-27b:free — то же семейство, что калиброванный
|
| 325 |
+
# Groq-Qwen, но другой эндпоинт: некалиброван, поэтому после проверенных, перед резервом.
|
| 326 |
+
"qwen/qwen3.8-27b:free",
|
| 327 |
"openrouter/free",
|
| 328 |
]
|
| 329 |
|
lumen_routes.py
CHANGED
|
@@ -281,6 +281,46 @@ async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *,
|
|
| 281 |
bot._record_quota_usage("groq", model_trial)
|
| 282 |
return answer
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
async def ask_openrouter_multimodal(
|
| 285 |
chat_id: int, user_text: str, media_tuple: tuple[bytes, str], media_filename: str,
|
| 286 |
model_chain: list[str], *, deadline: float | None = None,
|
|
|
|
| 281 |
bot._record_quota_usage("groq", model_trial)
|
| 282 |
return answer
|
| 283 |
|
| 284 |
+
async def _transcribe_audio(audio_bytes: bytes, chat_id: int) -> str | None:
|
| 285 |
+
"""Голос/аудио в текст через Groq Whisper (дешевле Gemini-квоты на порядок). None при
|
| 286 |
+
любой неудаче — вызывающий код молча идёт прежним путём (аудио напрямую в Gemini)."""
|
| 287 |
+
import bot
|
| 288 |
+
if not bot.GROQ_API_KEY or not audio_bytes:
|
| 289 |
+
return None
|
| 290 |
+
if len(audio_bytes) > bot.VOICE_TRANSCRIBE_MAX_BYTES:
|
| 291 |
+
log.warning("[groq] Voice message too big for transcription (%d bytes), leaving to Gemini.", len(audio_bytes))
|
| 292 |
+
return None
|
| 293 |
+
lang = bot._chat_lang(chat_id)
|
| 294 |
+
form = aiohttp.FormData()
|
| 295 |
+
form.add_field("file", audio_bytes, filename="voice.ogg", content_type="audio/ogg")
|
| 296 |
+
form.add_field("model", "whisper-large-v3-turbo")
|
| 297 |
+
if isinstance(lang, str) and len(lang) == 2:
|
| 298 |
+
form.add_field("language", lang)
|
| 299 |
+
try:
|
| 300 |
+
session = await bot._get_http_session()
|
| 301 |
+
async with session.post(
|
| 302 |
+
f"{bot.GROQ_BASE_URL}/audio/transcriptions",
|
| 303 |
+
headers={"Authorization": f"Bearer {bot.GROQ_API_KEY}"},
|
| 304 |
+
data=form,
|
| 305 |
+
timeout=aiohttp.ClientTimeout(total=bot.ROUTE_MODEL_TIMEOUT_SEC, connect=10.0),
|
| 306 |
+
) as resp:
|
| 307 |
+
if resp.status >= 400:
|
| 308 |
+
body = await resp.read()
|
| 309 |
+
log.warning("[groq] Whisper HTTP %s: %r", resp.status, body[:200])
|
| 310 |
+
return None
|
| 311 |
+
data = await resp.json(content_type=None)
|
| 312 |
+
except Exception as exc:
|
| 313 |
+
exc_str = str(exc) or repr(exc) or exc.__class__.__name__
|
| 314 |
+
if bot.GROQ_API_KEY:
|
| 315 |
+
exc_str = exc_str.replace(bot.GROQ_API_KEY, "<KEY>")
|
| 316 |
+
log.warning("[groq] Whisper request failed: %s", exc_str)
|
| 317 |
+
return None
|
| 318 |
+
text = (data.get("text") or "").strip() if isinstance(data, dict) else ""
|
| 319 |
+
if not text:
|
| 320 |
+
return None
|
| 321 |
+
bot._record_quota_usage("groq", "whisper-large-v3-turbo")
|
| 322 |
+
return text
|
| 323 |
+
|
| 324 |
async def ask_openrouter_multimodal(
|
| 325 |
chat_id: int, user_text: str, media_tuple: tuple[bytes, str], media_filename: str,
|
| 326 |
model_chain: list[str], *, deadline: float | None = None,
|
tests/test_bot_routes.py
CHANGED
|
@@ -1020,3 +1020,65 @@ def test_route_error_reply_text_maps_groq_error():
|
|
| 1020 |
assert "Rate limit reached" not in text
|
| 1021 |
assert text.strip() != ""
|
| 1022 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1020 |
assert "Rate limit reached" not in text
|
| 1021 |
assert text.strip() != ""
|
| 1022 |
|
| 1023 |
+
|
| 1024 |
+
def _fake_groq_audio_session(response_json, status=200):
|
| 1025 |
+
class _FakeResp:
|
| 1026 |
+
def __init__(self):
|
| 1027 |
+
self.status = status
|
| 1028 |
+
|
| 1029 |
+
async def __aenter__(self):
|
| 1030 |
+
return self
|
| 1031 |
+
|
| 1032 |
+
async def __aexit__(self, *args):
|
| 1033 |
+
return False
|
| 1034 |
+
|
| 1035 |
+
async def read(self):
|
| 1036 |
+
return b"{}"
|
| 1037 |
+
|
| 1038 |
+
async def json(self, content_type=None):
|
| 1039 |
+
return response_json
|
| 1040 |
+
|
| 1041 |
+
class _FakeSession:
|
| 1042 |
+
def post(self, *args, **kwargs):
|
| 1043 |
+
return _FakeResp()
|
| 1044 |
+
|
| 1045 |
+
async def fake_get_http_session():
|
| 1046 |
+
return _FakeSession()
|
| 1047 |
+
|
| 1048 |
+
return fake_get_http_session
|
| 1049 |
+
|
| 1050 |
+
|
| 1051 |
+
def test_transcribe_audio_success_records_quota(monkeypatch):
|
| 1052 |
+
# Groq Whisper отдал текст — пишем расход groq/whisper и возвращаем текст.
|
| 1053 |
+
monkeypatch.setattr(bot, "_get_http_session", _fake_groq_audio_session({"text": " привет мир "}))
|
| 1054 |
+
monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
|
| 1055 |
+
recorded = {}
|
| 1056 |
+
monkeypatch.setattr(bot, "_record_quota_usage", lambda provider, model: recorded.setdefault("v", (provider, model)))
|
| 1057 |
+
assert asyncio.run(bot._transcribe_audio(b"ogg-bytes", 123)) == "привет мир"
|
| 1058 |
+
assert recorded["v"] == ("groq", "whisper-large-v3-turbo")
|
| 1059 |
+
|
| 1060 |
+
|
| 1061 |
+
def test_transcribe_audio_empty_result_is_none(monkeypatch):
|
| 1062 |
+
# Пустая расшифровка — как неудача: вызывающий код идёт прежним путём.
|
| 1063 |
+
monkeypatch.setattr(bot, "_get_http_session", _fake_groq_audio_session({"text": " "}))
|
| 1064 |
+
monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
|
| 1065 |
+
assert asyncio.run(bot._transcribe_audio(b"ogg-bytes", 123)) is None
|
| 1066 |
+
|
| 1067 |
+
|
| 1068 |
+
def test_transcribe_audio_no_key_or_oversize_skips_network(monkeypatch):
|
| 1069 |
+
async def must_not_be_called():
|
| 1070 |
+
raise AssertionError("no network without key or for oversize audio")
|
| 1071 |
+
|
| 1072 |
+
monkeypatch.setattr(bot, "_get_http_session", must_not_be_called)
|
| 1073 |
+
monkeypatch.setattr(bot, "GROQ_API_KEY", "")
|
| 1074 |
+
assert asyncio.run(bot._transcribe_audio(b"ogg-bytes", 123)) is None
|
| 1075 |
+
monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
|
| 1076 |
+
monkeypatch.setattr(bot, "VOICE_TRANSCRIBE_MAX_BYTES", 4)
|
| 1077 |
+
assert asyncio.run(bot._transcribe_audio(b"too-big-bytes", 123)) is None
|
| 1078 |
+
|
| 1079 |
+
|
| 1080 |
+
def test_transcribe_audio_http_error_is_none(monkeypatch):
|
| 1081 |
+
monkeypatch.setattr(bot, "_get_http_session", _fake_groq_audio_session({}, status=503))
|
| 1082 |
+
monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
|
| 1083 |
+
assert asyncio.run(bot._transcribe_audio(b"ogg-bytes", 123)) is None
|
| 1084 |
+
|
tests/test_bot_state.py
CHANGED
|
@@ -561,6 +561,59 @@ def test_media_question_without_file_gets_no_file_notice(rate_guard_setup, monke
|
|
| 561 |
assert "не выдумывай" in prompt.lower()
|
| 562 |
|
| 563 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
def test_ordinary_question_gets_no_file_notice(rate_guard_setup, monkeypatch):
|
| 565 |
message = rate_guard_setup()
|
| 566 |
message.text = "столица Венгрии?"
|
|
|
|
| 561 |
assert "не выдумывай" in prompt.lower()
|
| 562 |
|
| 563 |
|
| 564 |
+
def test_voice_message_transcribed_into_normal_routing(rate_guard_setup, monkeypatch):
|
| 565 |
+
# Войс: транскрибация уходит в общий роутинг как текст, аудио дальше не едет.
|
| 566 |
+
message = rate_guard_setup()
|
| 567 |
+
message.text = ""
|
| 568 |
+
captured = {}
|
| 569 |
+
|
| 570 |
+
async def fake_resolve(message, state, clean_prompt, *, is_private):
|
| 571 |
+
return None, "", "", (b"ogg-bytes", "audio/ogg")
|
| 572 |
+
|
| 573 |
+
async def fake_transcribe(audio_bytes, chat_id):
|
| 574 |
+
assert audio_bytes == b"ogg-bytes"
|
| 575 |
+
return "текст из войса"
|
| 576 |
+
|
| 577 |
+
async def fake_run_route(chat_id, ai_prompt, route, message, **kwargs):
|
| 578 |
+
captured["prompt"] = ai_prompt
|
| 579 |
+
captured["media"] = kwargs.get("media")
|
| 580 |
+
return "ok", False
|
| 581 |
+
|
| 582 |
+
monkeypatch.setattr(bot, "_resolve_incoming_media", fake_resolve)
|
| 583 |
+
monkeypatch.setattr(bot, "_transcribe_audio", fake_transcribe)
|
| 584 |
+
monkeypatch.setattr(bot, "_run_route", fake_run_route)
|
| 585 |
+
monkeypatch.setattr(bot, "_safe_reply", AsyncMock())
|
| 586 |
+
asyncio.run(bot._handle_message_core(message))
|
| 587 |
+
assert "текст из войса" in captured["prompt"]
|
| 588 |
+
assert captured["media"] is None
|
| 589 |
+
bot.chat_state.pop(123, None)
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def test_voice_message_falls_back_to_gemini_audio_on_transcribe_failure(rate_guard_setup, monkeypatch):
|
| 593 |
+
# Whisper упал — войс идёт прежним путём (аудио в Gemini), а не в пустоту.
|
| 594 |
+
message = rate_guard_setup()
|
| 595 |
+
message.text = ""
|
| 596 |
+
captured = {}
|
| 597 |
+
|
| 598 |
+
async def fake_resolve(message, state, clean_prompt, *, is_private):
|
| 599 |
+
return None, "", "", (b"ogg-bytes", "audio/ogg")
|
| 600 |
+
|
| 601 |
+
async def fake_transcribe(audio_bytes, chat_id):
|
| 602 |
+
return None
|
| 603 |
+
|
| 604 |
+
async def fake_run_route(chat_id, ai_prompt, route, message, **kwargs):
|
| 605 |
+
captured["media"] = kwargs.get("media")
|
| 606 |
+
return "ok", False
|
| 607 |
+
|
| 608 |
+
monkeypatch.setattr(bot, "_resolve_incoming_media", fake_resolve)
|
| 609 |
+
monkeypatch.setattr(bot, "_transcribe_audio", fake_transcribe)
|
| 610 |
+
monkeypatch.setattr(bot, "_run_route", fake_run_route)
|
| 611 |
+
monkeypatch.setattr(bot, "_safe_reply", AsyncMock())
|
| 612 |
+
asyncio.run(bot._handle_message_core(message))
|
| 613 |
+
assert captured["media"] == [(b"ogg-bytes", "audio/ogg")]
|
| 614 |
+
bot.chat_state.pop(123, None)
|
| 615 |
+
|
| 616 |
+
|
| 617 |
def test_ordinary_question_gets_no_file_notice(rate_guard_setup, monkeypatch):
|
| 618 |
message = rate_guard_setup()
|
| 619 |
message.text = "столица Венгрии?"
|