Spaces:
Running
Running
SilverElixir commited on
Commit ·
c022ec9
1
Parent(s): 04a179f
Rename HF_IMAGE_* to POLLINATIONS_* (backend is Pollinations, not Hugging Face)
Browse files- bot.py +13 -13
- docs/ENVIRONMENT.md +1 -1
- lumen_images.py +10 -10
- test_bot.py +28 -28
- test_lumen_images.py +5 -5
bot.py
CHANGED
|
@@ -441,9 +441,9 @@ ROUTE_TOTAL_BUDGET_SEC = float(os.getenv("ROUTE_TOTAL_BUDGET_SEC", "40"))
|
|
| 441 |
# DRAW_TOTAL_BUDGET_SEC — тот же принцип, что и ROUTE_TOTAL_BUDGET_SEC выше, но для
|
| 442 |
# фолбэк-цепочки генерации изображений (см. inline_draw). НАЙДЕНО ПРИ КОД-РЕВЬЮ
|
| 443 |
# (28 августа 2026): в отличие от текстового роутинга, у /draw не было ВООБЩЕ
|
| 444 |
-
# никакого общего бюджета времени — каждый вызов
|
| 445 |
# (см. aiohttp.ClientTimeout в _pollinations_generate, lumen_images.py), а моделей
|
| 446 |
-
# в
|
| 447 |
# ждать до ~7.5 минут, прежде чем увидеть любую ошибку — статусное сообщение
|
| 448 |
# "Генерирую изображение" всё это время просто висело. 120с — достаточно на одну
|
| 449 |
# полную попытку (90с) плюс запас на вторую, но ограничивает худший случай вдвое
|
|
@@ -1954,7 +1954,7 @@ async def _is_privileged_in_chat(chat_type: str, chat_id: int, user_id: int | No
|
|
| 1954 |
# Pollinations.ai) вынесена в lumen_images.py — не пишет в chat_state/GLOBAL_QUOTA,
|
| 1955 |
# не зовёт Telegram API и не зависит от глобальных bot/client, самый изолированный
|
| 1956 |
# кандидат из пяти намеченных. Единственное отличие от прежнего кода:
|
| 1957 |
-
# _pollinations_generate/
|
| 1958 |
# параметром (см. докстринг модуля) — раньше сессия получалась неявно через
|
| 1959 |
# _get_http_session() внутри самой функции, что означало бы либо тянуть этот геттер
|
| 1960 |
# в новый модуль, либо заводить там свой отдельный источник сессий; вызывающий код
|
|
@@ -1964,17 +1964,17 @@ async def _is_privileged_in_chat(chat_type: str, chat_id: int, user_id: int | No
|
|
| 1964 |
# показывает названия моделей генерации ни в статусе, ни в подписи — см.
|
| 1965 |
# ИДЕНТИЧНОСТЬ в system_prompt.py. Сама функция живёт в lumen_images.py.
|
| 1966 |
from lumen_images import (
|
| 1967 |
-
|
| 1968 |
-
|
| 1969 |
_pick_image_model,
|
| 1970 |
-
|
| 1971 |
)
|
| 1972 |
|
| 1973 |
-
#
|
| 1974 |
# (используется только внутри самой _pick_image_model в lumen_images.py) — но
|
| 1975 |
-
# остаётся нужен как `bot.
|
| 1976 |
# пояснение про __all__ у первого блока (lumen_formatting) в начале файла.
|
| 1977 |
-
__all__ += ["
|
| 1978 |
|
| 1979 |
# метаданные и скачивание медиа
|
| 1980 |
|
|
@@ -4183,8 +4183,8 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 4183 |
primary_model = _pick_image_model(prompt)
|
| 4184 |
|
| 4185 |
# Фолбэк-цепочка: пробуем сначала подобранную модель,
|
| 4186 |
-
# при ошибке переключаемся на следующие по порядку из
|
| 4187 |
-
all_model_ids = list(
|
| 4188 |
fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
|
| 4189 |
|
| 4190 |
image_bytes = None
|
|
@@ -4207,7 +4207,7 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 4207 |
# На первой попытке статус и так "Генерирую изображение" — не трогаем.
|
| 4208 |
if attempt_model != primary_model:
|
| 4209 |
await _edit_message_quietly(status, "Это займёт немного больше времени…")
|
| 4210 |
-
image_bytes = await
|
| 4211 |
break
|
| 4212 |
except Exception as exc:
|
| 4213 |
last_error = exc
|
|
@@ -4242,7 +4242,7 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 4242 |
raise last_error or RuntimeError("all image generation models unavailable")
|
| 4243 |
|
| 4244 |
except Exception as exc:
|
| 4245 |
-
log.exception("
|
| 4246 |
txt = _error_text(exc).strip()
|
| 4247 |
if any(kw in txt.lower() for kw in ("cannot connect", "ssl:", "no address", "connection", "timeout", "host")):
|
| 4248 |
user_err = "Сервис генерации изображений временно недоступен. Попробуй позже."
|
|
|
|
| 441 |
# DRAW_TOTAL_BUDGET_SEC — тот же принцип, что и ROUTE_TOTAL_BUDGET_SEC выше, но для
|
| 442 |
# фолбэк-цепочки генерации изображений (см. inline_draw). НАЙДЕНО ПРИ КОД-РЕВЬЮ
|
| 443 |
# (28 августа 2026): в отличие от текстового роутинга, у /draw не было ВООБЩЕ
|
| 444 |
+
# никакого общего бюджета времени — каждый вызов _pollinations_text_to_image ждёт до 90с
|
| 445 |
# (см. aiohttp.ClientTimeout в _pollinations_generate, lumen_images.py), а моделей
|
| 446 |
+
# в POLLINATIONS_IMAGE_MODELS пять. Если Pollinations.ai лежит целиком, пользователь мог
|
| 447 |
# ждать до ~7.5 минут, прежде чем увидеть любую ошибку — статусное сообщение
|
| 448 |
# "Генерирую изображение" всё это время просто висело. 120с — достаточно на одну
|
| 449 |
# полную попытку (90с) плюс запас на вторую, но ограничивает худший случай вдвое
|
|
|
|
| 1954 |
# Pollinations.ai) вынесена в lumen_images.py — не пишет в chat_state/GLOBAL_QUOTA,
|
| 1955 |
# не зовёт Telegram API и не зависит от глобальных bot/client, самый изолированный
|
| 1956 |
# кандидат из пяти намеченных. Единственное отличие от прежнего кода:
|
| 1957 |
+
# _pollinations_generate/_pollinations_text_to_image теперь принимают уже готовую aiohttp-сессию
|
| 1958 |
# параметром (см. докстринг модуля) — раньше сессия получалась неявно через
|
| 1959 |
# _get_http_session() внутри самой функции, что означало бы либо тянуть этот геттер
|
| 1960 |
# в новый модуль, либо заводить там свой отдельный источник сессий; вызывающий код
|
|
|
|
| 1964 |
# показывает названия моделей генерации ни в статусе, ни в подписи — см.
|
| 1965 |
# ИДЕНТИЧНОСТЬ в system_prompt.py. Сама функция живёт в lumen_images.py.
|
| 1966 |
from lumen_images import (
|
| 1967 |
+
DEFAULT_POLLINATIONS_IMAGE_MODEL,
|
| 1968 |
+
POLLINATIONS_IMAGE_MODELS,
|
| 1969 |
_pick_image_model,
|
| 1970 |
+
_pollinations_text_to_image,
|
| 1971 |
)
|
| 1972 |
|
| 1973 |
+
# DEFAULT_POLLINATIONS_IMAGE_MODEL больше не читается напрямую нигде в остальном коде bot.py
|
| 1974 |
# (используется только внутри самой _pick_image_model в lumen_images.py) — но
|
| 1975 |
+
# остаётся нужен как `bot.DEFAULT_POLLINATIONS_IMAGE_MODEL` для существующих тестов. См.
|
| 1976 |
# пояснение про __all__ у первого блока (lumen_formatting) в начале файла.
|
| 1977 |
+
__all__ += ["DEFAULT_POLLINATIONS_IMAGE_MODEL"]
|
| 1978 |
|
| 1979 |
# метаданные и скачивание медиа
|
| 1980 |
|
|
|
|
| 4183 |
primary_model = _pick_image_model(prompt)
|
| 4184 |
|
| 4185 |
# Фолбэк-цепочка: пробуем сначала подобранную модель,
|
| 4186 |
+
# при ошибке переключаемся на следующие по порядку из POLLINATIONS_IMAGE_MODELS
|
| 4187 |
+
all_model_ids = list(POLLINATIONS_IMAGE_MODELS.keys())
|
| 4188 |
fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
|
| 4189 |
|
| 4190 |
image_bytes = None
|
|
|
|
| 4207 |
# На первой попытке статус и так "Генерирую изображение" — не трогаем.
|
| 4208 |
if attempt_model != primary_model:
|
| 4209 |
await _edit_message_quietly(status, "Это займёт немного больше времени…")
|
| 4210 |
+
image_bytes = await _pollinations_text_to_image(session, attempt_model, prompt)
|
| 4211 |
break
|
| 4212 |
except Exception as exc:
|
| 4213 |
last_error = exc
|
|
|
|
| 4242 |
raise last_error or RuntimeError("all image generation models unavailable")
|
| 4243 |
|
| 4244 |
except Exception as exc:
|
| 4245 |
+
log.exception("Pollinations image generation failed:")
|
| 4246 |
txt = _error_text(exc).strip()
|
| 4247 |
if any(kw in txt.lower() for kw in ("cannot connect", "ssl:", "no address", "connection", "timeout", "host")):
|
| 4248 |
user_err = "Сервис генерации изображений временно недоступен. Попробуй позже."
|
docs/ENVIRONMENT.md
CHANGED
|
@@ -50,7 +50,7 @@ Before publishing, configure the same `LUMEN_PROXY_SECRET` on HF and every Deno
|
|
| 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 |
| `OPENROUTER_HTTP_REFERER` | `https://t.me/{BOT_USERNAME}` | `HTTP-Referer` header sent with OpenRouter requests. |
|
| 52 |
| `OPENROUTER_TITLE` | `BOT_USERNAME` | App title header sent with OpenRouter requests. |
|
| 53 |
-
| `
|
| 54 |
|
| 55 |
## Timeouts, rate limiting & routing budgets
|
| 56 |
|
|
|
|
| 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 |
| `OPENROUTER_HTTP_REFERER` | `https://t.me/{BOT_USERNAME}` | `HTTP-Referer` header sent with OpenRouter requests. |
|
| 52 |
| `OPENROUTER_TITLE` | `BOT_USERNAME` | App title header sent with OpenRouter requests. |
|
| 53 |
+
| `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. |
|
| 54 |
|
| 55 |
## Timeouts, rate limiting & routing budgets
|
| 56 |
|
lumen_images.py
CHANGED
|
@@ -11,7 +11,7 @@ lumen_images.py — генерация изображений через Pollina
|
|
| 11 |
есть один общий httр-session getter (`_get_http_session`), которым пользуются TikTok/
|
| 12 |
OpenRouter/эта же генерация картинок — плодить второй, изолированный источник управления
|
| 13 |
HTTP-соединениями было бы речь не о разделении ответственности, а о случайном дублировании.
|
| 14 |
-
Поэтому `_pollinations_generate`/`
|
| 15 |
вызывающий код (см. `inline_draw` в bot.py) сам получает её через `_get_http_session()` и
|
| 16 |
передаёт сюда — то же самое соглашение, по которому TikTok-скачивание в bot.py принимает
|
| 17 |
сессию параметром в `_download_url_bin`/`_resolve_tiktok_short` и т.п.
|
|
@@ -37,9 +37,9 @@ from typing import Any
|
|
| 37 |
|
| 38 |
import aiohttp
|
| 39 |
|
| 40 |
-
|
| 41 |
|
| 42 |
-
|
| 43 |
"flux": {
|
| 44 |
"name": "FLUX Pro",
|
| 45 |
"desc": "Высококачественный FLUX. Фотореализм, точное следование промпту, богатая детализация.",
|
|
@@ -70,7 +70,7 @@ HF_IMAGE_MODELS: dict[str, dict[str, Any]] = {
|
|
| 70 |
# существующей fallback-цепочке (см. inline_draw в bot.py) при неудаче/плохом
|
| 71 |
# результате. Порядок проверки — от самых специфичных категорий к общей: аниме/
|
| 72 |
# фэнтези/реализм/черновик — явные сигналы жанра, при их отсутствии остаётся
|
| 73 |
-
#
|
| 74 |
_ANIME_RE = re.compile(r"аниме|манг[аи]|манхв\w*|вебтун\w*|чиби|ваифу|anime|manga|waifu|chibi", re.IGNORECASE)
|
| 75 |
_FANTASY_RE = re.compile(
|
| 76 |
r"фэнтези|фентези|фентезийн\w*|концепт-?арт\w*|дракон\w*|эльф\w*|волшебн\w*|магическ\w*|"
|
|
@@ -95,7 +95,7 @@ def _pick_image_model(prompt: str) -> str:
|
|
| 95 |
не стиль, поэтому последний перед дефолтом (стилевые сигналы важнее просьбы
|
| 96 |
"побыстрее", если оба есть в одном промпте)."""
|
| 97 |
if not prompt:
|
| 98 |
-
return
|
| 99 |
if _ANIME_RE.search(prompt):
|
| 100 |
return "flux-anime"
|
| 101 |
if _FANTASY_RE.search(prompt):
|
|
@@ -104,7 +104,7 @@ def _pick_image_model(prompt: str) -> str:
|
|
| 104 |
return "flux-realism"
|
| 105 |
if _QUICK_RE.search(prompt):
|
| 106 |
return "turbo"
|
| 107 |
-
return
|
| 108 |
|
| 109 |
|
| 110 |
async def _pollinations_generate(session: aiohttp.ClientSession, model_name: str, prompt: str) -> bytes:
|
|
@@ -126,17 +126,17 @@ async def _pollinations_generate(session: aiohttp.ClientSession, model_name: str
|
|
| 126 |
raise RuntimeError(f"Pollinations.ai HTTP {resp.status}")
|
| 127 |
|
| 128 |
|
| 129 |
-
async def
|
| 130 |
# Pollinations.ai — единственный провайдер генерации изображений (HF Inference
|
| 131 |
# API-ветка убрана: старые HF-модели регулярно устаревали на стороне провайдера,
|
| 132 |
# см. историю: FLUX.1-dev вернул 410 Gone). Раз провайдер всего один, отдельная
|
| 133 |
-
# "pollinations:" приставка на каждом ключе
|
| 134 |
# model_id и есть имя модели Pollinations как есть.
|
| 135 |
-
if model_id not in
|
| 136 |
raise ValueError(f"Неизвестная модель генерации изображений: {model_id}")
|
| 137 |
return await _pollinations_generate(session, model_id, prompt)
|
| 138 |
|
| 139 |
|
| 140 |
def _image_model_label(model_id: str) -> str:
|
| 141 |
-
meta =
|
| 142 |
return meta.get("name", model_id)
|
|
|
|
| 11 |
есть один общий httр-session getter (`_get_http_session`), которым пользуются TikTok/
|
| 12 |
OpenRouter/эта же генерация картинок — плодить второй, изолированный источник управления
|
| 13 |
HTTP-соединениями было бы речь не о разделении ответственности, а о случайном дублировании.
|
| 14 |
+
Поэтому `_pollinations_generate`/`_pollinations_text_to_image` принимают уже готовую сессию параметром;
|
| 15 |
вызывающий код (см. `inline_draw` в bot.py) сам получает её через `_get_http_session()` и
|
| 16 |
передаёт сюда — то же самое соглашение, по которому TikTok-скачивание в bot.py принимает
|
| 17 |
сессию параметром в `_download_url_bin`/`_resolve_tiktok_short` и т.п.
|
|
|
|
| 37 |
|
| 38 |
import aiohttp
|
| 39 |
|
| 40 |
+
DEFAULT_POLLINATIONS_IMAGE_MODEL = os.getenv("POLLINATIONS_IMAGE_MODEL", "flux").strip()
|
| 41 |
|
| 42 |
+
POLLINATIONS_IMAGE_MODELS: dict[str, dict[str, Any]] = {
|
| 43 |
"flux": {
|
| 44 |
"name": "FLUX Pro",
|
| 45 |
"desc": "Высококачественный FLUX. Фотореализм, точное следование промпту, богатая детализация.",
|
|
|
|
| 70 |
# существующей fallback-цепочке (см. inline_draw в bot.py) при неудаче/плохом
|
| 71 |
# результате. Порядок проверки — от самых специфичных категорий к общей: аниме/
|
| 72 |
# фэнтези/реализм/черновик — явные сигналы жанра, при их отсутствии остаётся
|
| 73 |
+
# DEFAULT_POLLINATIONS_IMAGE_MODEL (универсальный FLUX Pro).
|
| 74 |
_ANIME_RE = re.compile(r"аниме|манг[аи]|манхв\w*|вебтун\w*|чиби|ваифу|anime|manga|waifu|chibi", re.IGNORECASE)
|
| 75 |
_FANTASY_RE = re.compile(
|
| 76 |
r"фэнтези|фентези|фентезийн\w*|концепт-?арт\w*|дракон\w*|эльф\w*|волшебн\w*|магическ\w*|"
|
|
|
|
| 95 |
не стиль, поэтому последний перед дефолтом (стилевые сигналы важнее просьбы
|
| 96 |
"побыстрее", если оба есть в одном промпте)."""
|
| 97 |
if not prompt:
|
| 98 |
+
return DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 99 |
if _ANIME_RE.search(prompt):
|
| 100 |
return "flux-anime"
|
| 101 |
if _FANTASY_RE.search(prompt):
|
|
|
|
| 104 |
return "flux-realism"
|
| 105 |
if _QUICK_RE.search(prompt):
|
| 106 |
return "turbo"
|
| 107 |
+
return DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 108 |
|
| 109 |
|
| 110 |
async def _pollinations_generate(session: aiohttp.ClientSession, model_name: str, prompt: str) -> bytes:
|
|
|
|
| 126 |
raise RuntimeError(f"Pollinations.ai HTTP {resp.status}")
|
| 127 |
|
| 128 |
|
| 129 |
+
async def _pollinations_text_to_image(session: aiohttp.ClientSession, model_id: str, prompt: str) -> bytes:
|
| 130 |
# Pollinations.ai — единственный провайдер генерации изображений (HF Inference
|
| 131 |
# API-ветка убрана: старые HF-модели регулярно устаревали на стороне провайдера,
|
| 132 |
# см. историю: FLUX.1-dev вернул 410 Gone). Раз провайдер всего один, отдельная
|
| 133 |
+
# "pollinations:" приставка на каждом ключе POLLINATIONS_IMAGE_MODELS была лишней —
|
| 134 |
# model_id и есть имя модели Pollinations как есть.
|
| 135 |
+
if model_id not in POLLINATIONS_IMAGE_MODELS:
|
| 136 |
raise ValueError(f"Неизвестная модель генерации изображений: {model_id}")
|
| 137 |
return await _pollinations_generate(session, model_id, prompt)
|
| 138 |
|
| 139 |
|
| 140 |
def _image_model_label(model_id: str) -> str:
|
| 141 |
+
meta = POLLINATIONS_IMAGE_MODELS.get(model_id, {})
|
| 142 |
return meta.get("name", model_id)
|
test_bot.py
CHANGED
|
@@ -705,7 +705,7 @@ def test_save_chat_to_storage_returns_true_on_success(tmp_path):
|
|
| 705 |
# ниже): функция теперь ДОЛЖНА сигнализировать успех/неудачу вызывающему коду,
|
| 706 |
# а не просто логировать исключение и возвращать None в обоих случаях.
|
| 707 |
chat_id = 999601
|
| 708 |
-
state = {"history": [{"role": "user", "content": "привет"}], "image_model": bot.
|
| 709 |
original_chats_dir = bot._CHATS_DIR
|
| 710 |
bot._CHATS_DIR = tmp_path
|
| 711 |
try:
|
|
@@ -716,7 +716,7 @@ def test_save_chat_to_storage_returns_true_on_success(tmp_path):
|
|
| 716 |
|
| 717 |
|
| 718 |
def test_save_chat_to_storage_returns_false_on_failure():
|
| 719 |
-
state = {"history": [], "image_model": bot.
|
| 720 |
with patch("bot._storage_write_text", side_effect=RuntimeError("сбой хранилища")):
|
| 721 |
assert bot._save_chat_to_storage(999602, state) is False
|
| 722 |
|
|
@@ -749,8 +749,8 @@ def test_flush_dirty_state_once_requeues_failed_saves():
|
|
| 749 |
# заново. Теперь чат, для которого сохранение не удалось, должен остаться в
|
| 750 |
# _dirty_chat_ids и попасть в следующий цикл.
|
| 751 |
ok_chat, fail_chat = 999701, 999702
|
| 752 |
-
bot.chat_state[ok_chat] = {"history": [{"role": "user", "content": "ok"}], "image_model": bot.
|
| 753 |
-
bot.chat_state[fail_chat] = {"history": [{"role": "user", "content": "fail"}], "image_model": bot.
|
| 754 |
bot._dirty_chat_ids.clear()
|
| 755 |
bot._dirty_chat_ids.update({ok_chat, fail_chat})
|
| 756 |
bot._index_dirty = False
|
|
@@ -2793,8 +2793,8 @@ def test_pick_image_model_detects_quick_draft():
|
|
| 2793 |
|
| 2794 |
|
| 2795 |
def test_pick_image_model_falls_back_to_default_for_generic_prompt():
|
| 2796 |
-
assert bot._pick_image_model("космическая станция на орбите Земли") == bot.
|
| 2797 |
-
assert bot._pick_image_model("") == bot.
|
| 2798 |
|
| 2799 |
|
| 2800 |
def test_pick_image_model_style_keyword_wins_over_quick_keyword():
|
|
@@ -2838,7 +2838,7 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
|
|
| 2838 |
chat_id = 999430
|
| 2839 |
captured_model = []
|
| 2840 |
|
| 2841 |
-
async def
|
| 2842 |
captured_model.append(model_id)
|
| 2843 |
return b"\x89PNG fake bytes"
|
| 2844 |
|
|
@@ -2849,9 +2849,9 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
|
|
| 2849 |
async def send_photo(self, **kwargs):
|
| 2850 |
return SimpleNamespace()
|
| 2851 |
|
| 2852 |
-
original_hf = bot.
|
| 2853 |
original_bot_obj = bot.bot
|
| 2854 |
-
bot.
|
| 2855 |
bot.bot = _FakePhotoBot()
|
| 2856 |
try:
|
| 2857 |
asyncio.run(bot.inline_draw(incoming, "нарисуй девушку в стиле аниме на пляже"))
|
|
@@ -2860,7 +2860,7 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
|
|
| 2860 |
# завязан на состояние чата вообще.
|
| 2861 |
assert "image_model" not in bot.get_state(chat_id)
|
| 2862 |
finally:
|
| 2863 |
-
bot.
|
| 2864 |
bot.bot = original_bot_obj
|
| 2865 |
bot.chat_state.pop(chat_id, None)
|
| 2866 |
|
|
@@ -2868,14 +2868,14 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
|
|
| 2868 |
def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
|
| 2869 |
# Регрессия на находку код-ревью (28 августа 2026): раньше у /draw не было
|
| 2870 |
# общего бюджета времени на всю фолбэк-цепочку — при недоступности сервиса
|
| 2871 |
-
# генерации бот перебирал бы все 5 моделей
|
| 2872 |
# время пользователя без единого предупреждения. Патчим DRAW_TOTAL_BUDGET_SEC
|
| 2873 |
# на крошечное значение и делаем первую попытку заведомо дольше него —
|
| 2874 |
# вторая попытка не должна была вообще начаться.
|
| 2875 |
chat_id = 999432
|
| 2876 |
attempts = []
|
| 2877 |
|
| 2878 |
-
async def
|
| 2879 |
attempts.append(model_id)
|
| 2880 |
await asyncio.sleep(0.05) # дольше урезанного DRAW_TOTAL_BUDGET_SEC ниже
|
| 2881 |
raise RuntimeError("503 Service Unavailable")
|
|
@@ -2883,9 +2883,9 @@ def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
|
|
| 2883 |
incoming = _FakeIncomingMessage(chat_id)
|
| 2884 |
incoming.message_id = 1
|
| 2885 |
|
| 2886 |
-
original_hf = bot.
|
| 2887 |
original_budget = bot.DRAW_TOTAL_BUDGET_SEC
|
| 2888 |
-
bot.
|
| 2889 |
bot.DRAW_TOTAL_BUDGET_SEC = 0.01
|
| 2890 |
try:
|
| 2891 |
asyncio.run(bot.inline_draw(incoming, "нарисуй кота"))
|
|
@@ -2893,7 +2893,7 @@ def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
|
|
| 2893 |
assert len(attempts) == 1
|
| 2894 |
assert "времени" in incoming.sent[0].edits[-1][0].lower()
|
| 2895 |
finally:
|
| 2896 |
-
bot.
|
| 2897 |
bot.DRAW_TOTAL_BUDGET_SEC = original_budget
|
| 2898 |
|
| 2899 |
|
|
@@ -2905,7 +2905,7 @@ def test_inline_draw_falls_back_when_auto_picked_model_fails():
|
|
| 2905 |
chat_id = 999431
|
| 2906 |
attempts = []
|
| 2907 |
|
| 2908 |
-
async def
|
| 2909 |
attempts.append(model_id)
|
| 2910 |
if model_id == "flux-anime":
|
| 2911 |
raise RuntimeError("503 Service Unavailable")
|
|
@@ -2920,23 +2920,23 @@ def test_inline_draw_falls_back_when_auto_picked_model_fails():
|
|
| 2920 |
captured_photo.update(kwargs)
|
| 2921 |
return SimpleNamespace()
|
| 2922 |
|
| 2923 |
-
original_hf = bot.
|
| 2924 |
original_bot_obj = bot.bot
|
| 2925 |
-
bot.
|
| 2926 |
bot.bot = _FakePhotoBot()
|
| 2927 |
try:
|
| 2928 |
asyncio.run(bot.inline_draw(incoming, "нарисуй девушку в стиле аниме на пляже"))
|
| 2929 |
# Авто-подобранная модель (flux-anime) пробуется ПЕРВОЙ, несмотря на сбой.
|
| 2930 |
assert attempts[0] == "flux-anime"
|
| 2931 |
# Реально отправленное изображение — от следующей модели по порядку
|
| 2932 |
-
#
|
| 2933 |
assert len(attempts) >= 2
|
| 2934 |
assert captured_photo["photo"].data == b"\x89PNG fallback bytes"
|
| 2935 |
# Подпись с названием модели убрана (сентябрь 2026): бот не раскрывает
|
| 2936 |
# внутреннюю реализацию — ни названий, ни "основная недоступна".
|
| 2937 |
assert "caption" not in captured_photo
|
| 2938 |
finally:
|
| 2939 |
-
bot.
|
| 2940 |
bot.bot = original_bot_obj
|
| 2941 |
bot.chat_state.pop(chat_id, None)
|
| 2942 |
|
|
@@ -2949,29 +2949,29 @@ def test_inline_draw_stops_chain_on_service_rate_limit():
|
|
| 2949 |
chat_id = 999432
|
| 2950 |
attempts = []
|
| 2951 |
|
| 2952 |
-
async def
|
| 2953 |
attempts.append(model_id)
|
| 2954 |
raise RuntimeError("Pollinations.ai HTTP 429")
|
| 2955 |
|
| 2956 |
incoming = _FakeIncomingMessage(chat_id)
|
| 2957 |
incoming.message_id = 2
|
| 2958 |
|
| 2959 |
-
original_hf = bot.
|
| 2960 |
-
bot.
|
| 2961 |
try:
|
| 2962 |
asyncio.run(bot.inline_draw(incoming, "дикобраз"))
|
| 2963 |
assert attempts == [bot._pick_image_model("дикобраз")]
|
| 2964 |
status_texts = [text for text, _ in incoming.sent[0].edits]
|
| 2965 |
assert any("перегружен" in text for text in status_texts)
|
| 2966 |
finally:
|
| 2967 |
-
bot.
|
| 2968 |
bot.chat_state.pop(chat_id, None)
|
| 2969 |
|
| 2970 |
|
| 2971 |
# ─────────────────── schema_version персистентного снимка чата (ауди�� техдолга) ───────────────────
|
| 2972 |
|
| 2973 |
def test_serialize_chat_state_stamps_current_schema_version():
|
| 2974 |
-
state = {"image_model": bot.
|
| 2975 |
snapshot = bot._serialize_chat_state(state)
|
| 2976 |
assert snapshot["schema_version"] == bot.CHAT_STATE_SCHEMA_VERSION
|
| 2977 |
|
|
@@ -2981,7 +2981,7 @@ def test_restore_single_chat_accepts_legacy_record_without_schema_version():
|
|
| 2981 |
# восстановление не должно падать и должно вести себя так же, как раньше.
|
| 2982 |
cid = 999905
|
| 2983 |
try:
|
| 2984 |
-
bot._restore_single_chat(cid, {"image_model": bot.
|
| 2985 |
assert bot.chat_state[cid]["history"] == [{"role": "user", "content": "hi"}]
|
| 2986 |
finally:
|
| 2987 |
bot.chat_state.pop(cid, None)
|
|
@@ -2991,7 +2991,7 @@ def test_restore_single_chat_accepts_current_schema_version_record():
|
|
| 2991 |
cid = 999906
|
| 2992 |
try:
|
| 2993 |
snapshot = bot._serialize_chat_state({
|
| 2994 |
-
"image_model": bot.
|
| 2995 |
"quota": {}, "recent_media_ids": {},
|
| 2996 |
})
|
| 2997 |
bot._restore_single_chat(cid, snapshot)
|
|
@@ -3734,7 +3734,7 @@ def test_export_state_returns_chats_and_quota_with_valid_key():
|
|
| 3734 |
bot.ADMIN_PANEL_KEY = "real-admin-key"
|
| 3735 |
chat_id = 999411
|
| 3736 |
bot.chat_state[chat_id] = {
|
| 3737 |
-
"image_model": bot.
|
| 3738 |
"quota": {}, "recent_media_ids": {}, "last_activity": 0.0,
|
| 3739 |
}
|
| 3740 |
try:
|
|
|
|
| 705 |
# ниже): функция теперь ДОЛЖНА сигнализировать успех/неудачу вызывающему коду,
|
| 706 |
# а не просто логировать исключение и возвращать None в обоих случаях.
|
| 707 |
chat_id = 999601
|
| 708 |
+
state = {"history": [{"role": "user", "content": "привет"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
|
| 709 |
original_chats_dir = bot._CHATS_DIR
|
| 710 |
bot._CHATS_DIR = tmp_path
|
| 711 |
try:
|
|
|
|
| 716 |
|
| 717 |
|
| 718 |
def test_save_chat_to_storage_returns_false_on_failure():
|
| 719 |
+
state = {"history": [], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
|
| 720 |
with patch("bot._storage_write_text", side_effect=RuntimeError("сбой хранилища")):
|
| 721 |
assert bot._save_chat_to_storage(999602, state) is False
|
| 722 |
|
|
|
|
| 749 |
# заново. Теперь чат, для которого сохранение не удалось, должен остаться в
|
| 750 |
# _dirty_chat_ids и попасть в следующий цикл.
|
| 751 |
ok_chat, fail_chat = 999701, 999702
|
| 752 |
+
bot.chat_state[ok_chat] = {"history": [{"role": "user", "content": "ok"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
|
| 753 |
+
bot.chat_state[fail_chat] = {"history": [{"role": "user", "content": "fail"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
|
| 754 |
bot._dirty_chat_ids.clear()
|
| 755 |
bot._dirty_chat_ids.update({ok_chat, fail_chat})
|
| 756 |
bot._index_dirty = False
|
|
|
|
| 2793 |
|
| 2794 |
|
| 2795 |
def test_pick_image_model_falls_back_to_default_for_generic_prompt():
|
| 2796 |
+
assert bot._pick_image_model("космическая станция на орбите Земли") == bot.DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 2797 |
+
assert bot._pick_image_model("") == bot.DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 2798 |
|
| 2799 |
|
| 2800 |
def test_pick_image_model_style_keyword_wins_over_quick_keyword():
|
|
|
|
| 2838 |
chat_id = 999430
|
| 2839 |
captured_model = []
|
| 2840 |
|
| 2841 |
+
async def fake_pollinations_text_to_image(session, model_id, prompt):
|
| 2842 |
captured_model.append(model_id)
|
| 2843 |
return b"\x89PNG fake bytes"
|
| 2844 |
|
|
|
|
| 2849 |
async def send_photo(self, **kwargs):
|
| 2850 |
return SimpleNamespace()
|
| 2851 |
|
| 2852 |
+
original_hf = bot._pollinations_text_to_image
|
| 2853 |
original_bot_obj = bot.bot
|
| 2854 |
+
bot._pollinations_text_to_image = fake_pollinations_text_to_image
|
| 2855 |
bot.bot = _FakePhotoBot()
|
| 2856 |
try:
|
| 2857 |
asyncio.run(bot.inline_draw(incoming, "нарисуй девушку в стиле аниме на пляже"))
|
|
|
|
| 2860 |
# завязан на состояние чата вообще.
|
| 2861 |
assert "image_model" not in bot.get_state(chat_id)
|
| 2862 |
finally:
|
| 2863 |
+
bot._pollinations_text_to_image = original_hf
|
| 2864 |
bot.bot = original_bot_obj
|
| 2865 |
bot.chat_state.pop(chat_id, None)
|
| 2866 |
|
|
|
|
| 2868 |
def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
|
| 2869 |
# Регрессия на находку код-ревью (28 августа 2026): раньше у /draw не было
|
| 2870 |
# общего бюджета времени на всю фолбэк-цепочку — при недоступности сервиса
|
| 2871 |
+
# генерации бот перебирал бы все 5 моделей POLLINATIONS_IMAGE_MODELS, тратя реальное
|
| 2872 |
# время пользователя без единого предупреждения. Патчим DRAW_TOTAL_BUDGET_SEC
|
| 2873 |
# на крошечное значение и делаем первую попытку заведомо дольше него —
|
| 2874 |
# вторая попытка не должна была вообще начаться.
|
| 2875 |
chat_id = 999432
|
| 2876 |
attempts = []
|
| 2877 |
|
| 2878 |
+
async def fake_pollinations_text_to_image(session, model_id, prompt):
|
| 2879 |
attempts.append(model_id)
|
| 2880 |
await asyncio.sleep(0.05) # дольше урезанного DRAW_TOTAL_BUDGET_SEC ниже
|
| 2881 |
raise RuntimeError("503 Service Unavailable")
|
|
|
|
| 2883 |
incoming = _FakeIncomingMessage(chat_id)
|
| 2884 |
incoming.message_id = 1
|
| 2885 |
|
| 2886 |
+
original_hf = bot._pollinations_text_to_image
|
| 2887 |
original_budget = bot.DRAW_TOTAL_BUDGET_SEC
|
| 2888 |
+
bot._pollinations_text_to_image = fake_pollinations_text_to_image
|
| 2889 |
bot.DRAW_TOTAL_BUDGET_SEC = 0.01
|
| 2890 |
try:
|
| 2891 |
asyncio.run(bot.inline_draw(incoming, "нарисуй кота"))
|
|
|
|
| 2893 |
assert len(attempts) == 1
|
| 2894 |
assert "времени" in incoming.sent[0].edits[-1][0].lower()
|
| 2895 |
finally:
|
| 2896 |
+
bot._pollinations_text_to_image = original_hf
|
| 2897 |
bot.DRAW_TOTAL_BUDGET_SEC = original_budget
|
| 2898 |
|
| 2899 |
|
|
|
|
| 2905 |
chat_id = 999431
|
| 2906 |
attempts = []
|
| 2907 |
|
| 2908 |
+
async def fake_pollinations_text_to_image(session, model_id, prompt):
|
| 2909 |
attempts.append(model_id)
|
| 2910 |
if model_id == "flux-anime":
|
| 2911 |
raise RuntimeError("503 Service Unavailable")
|
|
|
|
| 2920 |
captured_photo.update(kwargs)
|
| 2921 |
return SimpleNamespace()
|
| 2922 |
|
| 2923 |
+
original_hf = bot._pollinations_text_to_image
|
| 2924 |
original_bot_obj = bot.bot
|
| 2925 |
+
bot._pollinations_text_to_image = fake_pollinations_text_to_image
|
| 2926 |
bot.bot = _FakePhotoBot()
|
| 2927 |
try:
|
| 2928 |
asyncio.run(bot.inline_draw(incoming, "нарисуй девушку в стиле аниме на пляже"))
|
| 2929 |
# Авто-подобранная модель (flux-anime) пробуется ПЕРВОЙ, несмотря на сбой.
|
| 2930 |
assert attempts[0] == "flux-anime"
|
| 2931 |
# Реально отправленное изображение — от следующей модели по порядку
|
| 2932 |
+
# POLLINATIONS_IMAGE_MODELS, а не от auto-pick, провалившегося с ошибкой.
|
| 2933 |
assert len(attempts) >= 2
|
| 2934 |
assert captured_photo["photo"].data == b"\x89PNG fallback bytes"
|
| 2935 |
# Подпись с названием модели убрана (сентябрь 2026): бот не раскрывает
|
| 2936 |
# внутреннюю реализацию — ни названий, ни "основная недоступна".
|
| 2937 |
assert "caption" not in captured_photo
|
| 2938 |
finally:
|
| 2939 |
+
bot._pollinations_text_to_image = original_hf
|
| 2940 |
bot.bot = original_bot_obj
|
| 2941 |
bot.chat_state.pop(chat_id, None)
|
| 2942 |
|
|
|
|
| 2949 |
chat_id = 999432
|
| 2950 |
attempts = []
|
| 2951 |
|
| 2952 |
+
async def fake_pollinations_text_to_image_always_429(session, model_id, prompt):
|
| 2953 |
attempts.append(model_id)
|
| 2954 |
raise RuntimeError("Pollinations.ai HTTP 429")
|
| 2955 |
|
| 2956 |
incoming = _FakeIncomingMessage(chat_id)
|
| 2957 |
incoming.message_id = 2
|
| 2958 |
|
| 2959 |
+
original_hf = bot._pollinations_text_to_image
|
| 2960 |
+
bot._pollinations_text_to_image = fake_pollinations_text_to_image_always_429
|
| 2961 |
try:
|
| 2962 |
asyncio.run(bot.inline_draw(incoming, "дикобраз"))
|
| 2963 |
assert attempts == [bot._pick_image_model("дикобраз")]
|
| 2964 |
status_texts = [text for text, _ in incoming.sent[0].edits]
|
| 2965 |
assert any("перегружен" in text for text in status_texts)
|
| 2966 |
finally:
|
| 2967 |
+
bot._pollinations_text_to_image = original_hf
|
| 2968 |
bot.chat_state.pop(chat_id, None)
|
| 2969 |
|
| 2970 |
|
| 2971 |
# ─────────────────── schema_version персистентного снимка чата (ауди�� техдолга) ───────────────────
|
| 2972 |
|
| 2973 |
def test_serialize_chat_state_stamps_current_schema_version():
|
| 2974 |
+
state = {"image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "history": [], "quota": {}, "recent_media_ids": {}}
|
| 2975 |
snapshot = bot._serialize_chat_state(state)
|
| 2976 |
assert snapshot["schema_version"] == bot.CHAT_STATE_SCHEMA_VERSION
|
| 2977 |
|
|
|
|
| 2981 |
# восстановление не должно падать и должно вести себя так же, как раньше.
|
| 2982 |
cid = 999905
|
| 2983 |
try:
|
| 2984 |
+
bot._restore_single_chat(cid, {"image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}]})
|
| 2985 |
assert bot.chat_state[cid]["history"] == [{"role": "user", "content": "hi"}]
|
| 2986 |
finally:
|
| 2987 |
bot.chat_state.pop(cid, None)
|
|
|
|
| 2991 |
cid = 999906
|
| 2992 |
try:
|
| 2993 |
snapshot = bot._serialize_chat_state({
|
| 2994 |
+
"image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}],
|
| 2995 |
"quota": {}, "recent_media_ids": {},
|
| 2996 |
})
|
| 2997 |
bot._restore_single_chat(cid, snapshot)
|
|
|
|
| 3734 |
bot.ADMIN_PANEL_KEY = "real-admin-key"
|
| 3735 |
chat_id = 999411
|
| 3736 |
bot.chat_state[chat_id] = {
|
| 3737 |
+
"image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}],
|
| 3738 |
"quota": {}, "recent_media_ids": {}, "last_activity": 0.0,
|
| 3739 |
}
|
| 3740 |
try:
|
test_lumen_images.py
CHANGED
|
@@ -39,12 +39,12 @@ def test_pick_image_model_quick_draft():
|
|
| 39 |
|
| 40 |
|
| 41 |
def test_pick_image_model_falls_back_to_default_for_generic_prompt():
|
| 42 |
-
assert lumen_images._pick_image_model("космическая станция на орбите Земли") == lumen_images.
|
| 43 |
-
assert lumen_images._pick_image_model("кот на подоконнике") == lumen_images.
|
| 44 |
|
| 45 |
|
| 46 |
def test_pick_image_model_empty_prompt_returns_default():
|
| 47 |
-
assert lumen_images._pick_image_model("") == lumen_images.
|
| 48 |
|
| 49 |
|
| 50 |
def test_pick_image_model_style_keyword_wins_over_quick_keyword():
|
|
@@ -59,10 +59,10 @@ def test_pick_image_model_case_insensitive():
|
|
| 59 |
|
| 60 |
def test_pick_image_model_result_always_a_known_model():
|
| 61 |
# Регрессия на класс ошибок "эвристика вернула ID, которого нет в каталоге" —
|
| 62 |
-
# неважно, какой промпт, результат обязан быть валидным ключом
|
| 63 |
prompts = [
|
| 64 |
"нарисуй кота", "аниме", "фэнтези дракон", "реалистичное фото гор",
|
| 65 |
"быстрый скетч", "", "случайный текст без ключевых слов вообще",
|
| 66 |
]
|
| 67 |
for p in prompts:
|
| 68 |
-
assert lumen_images._pick_image_model(p) in lumen_images.
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def test_pick_image_model_falls_back_to_default_for_generic_prompt():
|
| 42 |
+
assert lumen_images._pick_image_model("космическая станция на орбите Земли") == lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 43 |
+
assert lumen_images._pick_image_model("кот на подоконнике") == lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 44 |
|
| 45 |
|
| 46 |
def test_pick_image_model_empty_prompt_returns_default():
|
| 47 |
+
assert lumen_images._pick_image_model("") == lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 48 |
|
| 49 |
|
| 50 |
def test_pick_image_model_style_keyword_wins_over_quick_keyword():
|
|
|
|
| 59 |
|
| 60 |
def test_pick_image_model_result_always_a_known_model():
|
| 61 |
# Регрессия на класс ошибок "эвристика вернула ID, которого нет в каталоге" —
|
| 62 |
+
# неважно, какой промпт, результат обязан быть валидным ключом POLLINATIONS_IMAGE_MODELS.
|
| 63 |
prompts = [
|
| 64 |
"нарисуй кота", "аниме", "фэнтези дракон", "реалистичное фото гор",
|
| 65 |
"быстрый скетч", "", "случайный текст без ключевых слов вообще",
|
| 66 |
]
|
| 67 |
for p in prompts:
|
| 68 |
+
assert lumen_images._pick_image_model(p) in lumen_images.POLLINATIONS_IMAGE_MODELS
|