Spaces:
Running
Running
SilverElixir commited on
Add files via upload
Browse files- README.md +5 -1
- bot.py +50 -128
- lumen_images.py +51 -39
- lumen_state_storage.py +6 -18
- test_bot.py +75 -82
- test_lumen_images.py +68 -0
README.md
CHANGED
|
@@ -132,7 +132,7 @@ HF Spaces не имеет прямого исходящего доступа к
|
|
| 132 |
|
| 133 |
Ограничение "только в личных сообщениях" для `/stats`/`/logs` добавлено при код-ревью: результат команды видят ВСЕ участники чата, в котором она вызвана — если бы владелец случайно вызвал их в общем групповом чате, реальные технические детали (ID моделей и т.п.) увидели бы все, кто состоит в группе. Если вызвать в группе — бот вежливо попросит написать в личку, вместо того чтобы молча показать данные всем или молча проигнорировать команду.
|
| 134 |
|
| 135 |
-
Команды `/model`
|
| 136 |
|
| 137 |
## Текстовые триггеры (без команд)
|
| 138 |
|
|
@@ -158,6 +158,10 @@ HF Spaces не имеет прямого исходящего доступа к
|
|
| 158 |
|
| 159 |
Внутри одного провайдера каждая модель пробуется РОВНО один раз — без ретраев (см. следующий раздел про скорость ответа). Общий бюджет времени на весь маршрут (оба провайдера) — `ROUTE_TOTAL_BUDGET_SEC` (по умолчанию 40 сек); при превышении бот честно говорит, что сейчас всё перегружено, вместо многоминутного ожидания.
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
## Скорость ответа: без ретраев одной модели
|
| 162 |
|
| 163 |
Раньше при таймауте/503/500 бот ретраил ОДНУ и ту же модель дважды с экспоненциальной задержкой (1 → 2 → 4 сек), и только потом переключался на следующую в цепочке — при нестабильности API это реально давало ответы по 2+ минуты (несколько моделей подряд, каждая — до 3 попыток по `ROUTE_MODEL_TIMEOUT_SEC`). Теперь ретраев одной модели нет вообще: любая ошибка (таймаут, 429, 503/500, что угодно ещё) сразу переключает на следующую модель в маршруте, без пауз. В худшем случае время о��вета ограничено `len(маршрута) × ROUTE_MODEL_TIMEOUT_SEC`, а сверху ещё режется общим `ROUTE_TOTAL_BUDGET_SEC` на весь маршрут.
|
|
|
|
| 132 |
|
| 133 |
Ограничение "только в личных сообщениях" для `/stats`/`/logs` добавлено при код-ревью: результат команды видят ВСЕ участники чата, в котором она вызвана — если бы владелец случайно вызвал их в общем групповом чате, реальные технические детали (ID моделей и т.п.) увидели бы все, кто состоит в группе. Если вызвать в группе — бот вежливо попросит написать в личку, вместо того чтобы молча показать данные всем или молча проигнорировать команду.
|
| 134 |
|
| 135 |
+
Команды `/model`/`/provider` (ручной выбор текстовой модели/провайдера) и `/imgmodel` (ручной выбор модели генерации изображений) удалены полностью — см. раздел "Автоматический выбор модели" ниже: теперь и текстовая модель/провайдер, и модель генерации картинок бот выбирает сам на каждый запрос.
|
| 136 |
|
| 137 |
## Текстовые триггеры (без команд)
|
| 138 |
|
|
|
|
| 158 |
|
| 159 |
Внутри одного провайдера каждая модель пробуется РОВНО один раз — без ретраев (см. следующий раздел про скорость ответа). Общий бюджет времени на весь маршрут (оба провайдера) — `ROUTE_TOTAL_BUDGET_SEC` (по умолчанию 40 сек); при превышении бот честно говорит, что сейчас всё перегружено, вместо многоминутного ожидания.
|
| 160 |
|
| 161 |
+
### Автоматический выбор модели для генерации изображений
|
| 162 |
+
|
| 163 |
+
Команда `/imgmodel` (ручной выбор модели через `/draw`) удалена по тому же принципу (аудит техдолга, 19 августа 2026): `_pick_image_model` (`lumen_images.py`) подбирает модель по содержимому промпта на каждый вызов `inline_draw`, без persistent-состояния по чату — грубая эвристика по ключевым словам жанра (аниме/манга → `flux-anime`, фэнтези/концепт-арт → `dreamshaper`, "фотореалистично"/"как фото" → `flux-realism`, "быстро"/"набросок"/"черновик" → `turbo`, иначе — универсальный `flux`), тот же класс решения, что уже применяется к тексту (`_looks_like_heavy_query`/`_looks_like_freshness_query`) — отдельный классифицирующий вызов LLM стоил бы дороже, чем просто попробовать разумный дефолт. Фолбэк-цепочка на случай отказа подобранной модели (перебор остальных моделей `HF_IMAGE_MODELS` по порядку) не изменилась.
|
| 164 |
+
|
| 165 |
## Скорость ответа: без ретраев одной модели
|
| 166 |
|
| 167 |
Раньше при таймауте/503/500 бот ретраил ОДНУ и ту же модель дважды с экспоненциальной задержкой (1 → 2 → 4 сек), и только потом переключался на следующую в цепочке — при нестабильности API это реально давало ответы по 2+ минуты (несколько моделей подряд, каждая — до 3 попыток по `ROUTE_MODEL_TIMEOUT_SEC`). Теперь ретраев одной модели нет вообще: любая ошибка (таймаут, 429, 503/500, что угодно ещё) сразу переключает на следующую модель в маршруте, без пауз. В худшем случае время о��вета ограничено `len(маршрута) × ROUTE_MODEL_TIMEOUT_SEC`, а сверху ещё режется общим `ROUTE_TOTAL_BUDGET_SEC` на весь маршрут.
|
bot.py
CHANGED
|
@@ -34,7 +34,7 @@ from urllib.parse import urlparse
|
|
| 34 |
import aiohttp
|
| 35 |
import sentry_sdk
|
| 36 |
import uvicorn
|
| 37 |
-
from aiogram import Bot, Dispatcher
|
| 38 |
from aiogram.client.telegram import TelegramAPIServer
|
| 39 |
from aiogram.exceptions import TelegramEntityTooLarge
|
| 40 |
from aiogram.enums import ChatType, ParseMode
|
|
@@ -936,7 +936,6 @@ def get_system_prompt(model_id: str | None = None) -> str:
|
|
| 936 |
# остаётся обычным dict, никакой валидации здесь не добавляется — это только
|
| 937 |
# документация формы для статических проверок типов и читаемости.
|
| 938 |
class ChatState(TypedDict, total=False):
|
| 939 |
-
image_model: str
|
| 940 |
history: list[dict[str, Any]]
|
| 941 |
quota: dict[str, Any]
|
| 942 |
ctx: "deque[str]"
|
|
@@ -1199,7 +1198,6 @@ from lumen_state_storage import (
|
|
| 1199 |
StorageConfig,
|
| 1200 |
_chat_storage_key,
|
| 1201 |
_serialize_chat_state,
|
| 1202 |
-
_normalize_legacy_image_model_id,
|
| 1203 |
_current_quota_day,
|
| 1204 |
_upstash_request as _lumen_upstash_request,
|
| 1205 |
_upstash_set as _lumen_upstash_set,
|
|
@@ -1396,54 +1394,39 @@ def save_global_quota() -> None:
|
|
| 1396 |
log.warning("[quota] Failed to save global quota: %s", exc)
|
| 1397 |
|
| 1398 |
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: до сих пор каждый формат-дрейф персистентного
|
| 1399 |
-
# снимка чата (слияние gemini_history/or_history в единую history
|
| 1400 |
-
# пр
|
| 1401 |
-
#
|
| 1402 |
-
#
|
| 1403 |
-
#
|
| 1404 |
-
#
|
| 1405 |
-
#
|
| 1406 |
-
#
|
| 1407 |
-
#
|
| 1408 |
-
#
|
| 1409 |
-
#
|
| 1410 |
-
#
|
| 1411 |
-
#
|
| 1412 |
-
# ретроактивная миграция уже написанной логики).
|
| 1413 |
|
| 1414 |
def _restore_single_chat(cid: int, s: dict[str, Any]) -> None:
|
| 1415 |
"""Разворачивает сериализованный снимок одного чата (см. _serialize_chat_state)
|
| 1416 |
обратно в chat_state[cid] — общая логика между новым per-chat форматом чтения
|
| 1417 |
и одноразовой миграцией из старо��о общего блоба (см. load_state_from_disk).
|
| 1418 |
|
| 1419 |
-
Поля "gemini_model"/"openrouter_text_model"/"chat_provider" из
|
| 1420 |
-
(созданных до перехода на автоматический роутер
|
| 1421 |
-
|
|
|
|
| 1422 |
|
| 1423 |
schema_version (см. CHAT_STATE_SCHEMA_VERSION выше) в самих записях, читаемых
|
| 1424 |
-
здесь, пока ни на что не влияет — существующ
|
| 1425 |
-
|
| 1426 |
-
|
| 1427 |
-
|
| 1428 |
-
подбирать очередную эвристику по ключам, как приходилось делать для миграци
|
|
|
|
| 1429 |
schema_version = s.get("schema_version", 0)
|
| 1430 |
log.debug('[state] Restoring chat %s (schema_version=%s)', cid, schema_version)
|
| 1431 |
-
# НАЙДЕНО ПРИ /code-review (после ponytail-audit): переименование ключей
|
| 1432 |
-
# HF_IMAGE_MODELS (см. _hf_text_to_image — убрана лишняя приставка
|
| 1433 |
-
# "pollinations:", единственный провайдер и так один) — не просто
|
| 1434 |
-
# косметика, если в Upstash/локальном файле УЖЕ лежит персистентное
|
| 1435 |
-
# состояние чата с image_model в старом формате ("pollinations:turbo" и
|
| 1436 |
-
# т.п.). Без миграции ниже такой чат при первой же загрузке молча (без
|
| 1437 |
-
# предупреждения, без лога) терял бы выбранную пользователем модель —
|
| 1438 |
-
# старое значение просто не совпало бы ни с одним новым ключом и
|
| 1439 |
-
# откатилось бы на DEFAULT_HF_IMAGE_MODEL. _normalize_legacy_image_model_id
|
| 1440 |
-
# снимает старую приставку перед проверкой, так что реальный выбор
|
| 1441 |
-
# пользователя переживает этот рефакторинг так же, как переживает и любой
|
| 1442 |
-
# другой формат старых записей (см. миграцию history/gemini_history чуть
|
| 1443 |
-
# ниже — тот же принцип, тот же файл).
|
| 1444 |
-
image_model = _normalize_legacy_image_model_id(s.get("image_model", DEFAULT_HF_IMAGE_MODEL))
|
| 1445 |
-
if image_model not in HF_IMAGE_MODELS:
|
| 1446 |
-
image_model = DEFAULT_HF_IMAGE_MODEL
|
| 1447 |
raw_media = s.get("recent_media_ids", {})
|
| 1448 |
if isinstance(raw_media, dict):
|
| 1449 |
media_buckets = {
|
|
@@ -1466,7 +1449,6 @@ def _restore_single_chat(cid: int, s: dict[str, Any]) -> None:
|
|
| 1466 |
if len(history) > SHARED_HISTORY_MAX_LEN:
|
| 1467 |
history = history[-SHARED_HISTORY_MAX_LEN:]
|
| 1468 |
chat_state[cid] = {
|
| 1469 |
-
"image_model": image_model,
|
| 1470 |
"history": history,
|
| 1471 |
"quota": s.get("quota", {}),
|
| 1472 |
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
|
@@ -1696,7 +1678,6 @@ def load_state_from_disk() -> None:
|
|
| 1696 |
def get_state(chat_id: int) -> dict[str, Any]:
|
| 1697 |
if chat_id not in chat_state:
|
| 1698 |
chat_state[chat_id] = {
|
| 1699 |
-
"image_model": DEFAULT_HF_IMAGE_MODEL,
|
| 1700 |
"history": [],
|
| 1701 |
"quota": {},
|
| 1702 |
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
|
@@ -1709,11 +1690,6 @@ def get_state(chat_id: int) -> dict[str, Any]:
|
|
| 1709 |
_mark_new_chat_id(chat_id)
|
| 1710 |
else:
|
| 1711 |
chat_state[chat_id]["last_activity"] = time.monotonic()
|
| 1712 |
-
normalized = _normalize_legacy_image_model_id(chat_state[chat_id].get("image_model"))
|
| 1713 |
-
if normalized not in HF_IMAGE_MODELS:
|
| 1714 |
-
chat_state[chat_id]["image_model"] = DEFAULT_HF_IMAGE_MODEL
|
| 1715 |
-
elif normalized != chat_state[chat_id].get("image_model"):
|
| 1716 |
-
chat_state[chat_id]["image_model"] = normalized
|
| 1717 |
if len(chat_state) > MAX_CHAT_LIMIT:
|
| 1718 |
_prune_old_chats()
|
| 1719 |
return chat_state[chat_id]
|
|
@@ -1787,29 +1763,29 @@ async def _is_privileged_in_chat(chat_type: str, chat_id: int, user_id: int | No
|
|
| 1787 |
# генерация изображений (pollinations)
|
| 1788 |
#
|
| 1789 |
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА (разбиение bot.py на модули): вся эта секция (каталог
|
| 1790 |
-
# моделей Pollinations,
|
| 1791 |
-
# lumen_images.py — не пишет в chat_state/GLOBAL_QUOTA,
|
| 1792 |
-
# зависит от глобальных bot/client, самый изолированный
|
| 1793 |
-
# Единственное отличие от прежнего кода:
|
| 1794 |
-
# теперь принимают уже готовую aiohttp-сессию
|
| 1795 |
-
# раньше сессия получалась неявно через
|
| 1796 |
-
# что означало бы либо тянуть этот геттер
|
| 1797 |
-
# отдельный источник сессий; вызывающий код
|
| 1798 |
-
# сессию и передаёт её. Публичные имена и
|
|
|
|
| 1799 |
from lumen_images import (
|
| 1800 |
DEFAULT_HF_IMAGE_MODEL,
|
| 1801 |
HF_IMAGE_MODELS,
|
| 1802 |
-
|
| 1803 |
-
_imgmodel_keyboard,
|
| 1804 |
_hf_text_to_image,
|
| 1805 |
_image_model_label,
|
| 1806 |
)
|
| 1807 |
|
| 1808 |
-
#
|
| 1809 |
-
# только внутри самой
|
| 1810 |
-
# `bot.
|
| 1811 |
-
# первого блока (lumen_formatting) в начале файла.
|
| 1812 |
-
__all__ += ["
|
| 1813 |
|
| 1814 |
# метаданные и скачивание медиа
|
| 1815 |
|
|
@@ -3673,8 +3649,7 @@ async def cmd_start(message: Message) -> None:
|
|
| 3673 |
"Я отвечаю на вопросы с поиском в реальном времени, разбираю фото, видео, аудио и документы, рисую изображения по описанию и озвучиваю текст голосом.\n\n"
|
| 3674 |
"<b>Команды</b>\n"
|
| 3675 |
"/draw [описание] — нарисовать изображение\n"
|
| 3676 |
-
"/tts [текст] — озвучить текст голосом\n"
|
| 3677 |
-
"/imgmodel — модель для генерации изображений\n\n"
|
| 3678 |
"Рисовать и озвучивать можно и просто словами, без команд — например «нарисуй кота» или «озвучь это».\n\n"
|
| 3679 |
"<b>TikTok</b>\n"
|
| 3680 |
"Просто пришлите ссылку — скачаю видео или фото без водяных знаков.\n\n"
|
|
@@ -3686,13 +3661,14 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 3686 |
status = await _tg_call(message.reply, "Генерирую изображение")
|
| 3687 |
try:
|
| 3688 |
session = await _get_http_session()
|
| 3689 |
-
|
| 3690 |
-
|
| 3691 |
-
|
| 3692 |
-
|
| 3693 |
-
|
| 3694 |
-
|
| 3695 |
-
|
|
|
|
| 3696 |
# при ошибке переключаемся на следующие по порядку из HF_IMAGE_MODELS
|
| 3697 |
all_model_ids = list(HF_IMAGE_MODELS.keys())
|
| 3698 |
fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
|
|
@@ -3748,63 +3724,10 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 3748 |
else:
|
| 3749 |
# Сырой текст ошибки провайдера пользователю не показываем (см. log.exception
|
| 3750 |
# выше) — та же логика, что и в остальных обработчиках ошибок бота.
|
| 3751 |
-
user_err = "Ошибка генерации изображения. Попробуйте
|
| 3752 |
await _edit_message_quietly(status, user_err)
|
| 3753 |
|
| 3754 |
|
| 3755 |
-
@dp.message(Command("imgmodel"))
|
| 3756 |
-
async def cmd_imgmodel(message: Message) -> None:
|
| 3757 |
-
state = get_state(message.chat.id)
|
| 3758 |
-
model_id = state.get("image_model") or DEFAULT_HF_IMAGE_MODEL
|
| 3759 |
-
if model_id not in HF_IMAGE_MODELS:
|
| 3760 |
-
model_id = DEFAULT_HF_IMAGE_MODEL
|
| 3761 |
-
state["image_model"] = model_id
|
| 3762 |
-
meta = HF_IMAGE_MODELS.get(model_id, {})
|
| 3763 |
-
await _tg_call(
|
| 3764 |
-
message.reply,
|
| 3765 |
-
f"<b>Текущая модель:</b> {_html_mod.escape(meta.get('name', model_id))}\n"
|
| 3766 |
-
f"{_html_mod.escape(meta.get('desc', ''))}\n\n"
|
| 3767 |
-
f"Выберите другую модель ниже:",
|
| 3768 |
-
reply_markup=_imgmodel_keyboard(model_id),
|
| 3769 |
-
parse_mode=ParseMode.HTML,
|
| 3770 |
-
)
|
| 3771 |
-
|
| 3772 |
-
|
| 3773 |
-
@dp.callback_query(F.data.startswith("imgmodel:"))
|
| 3774 |
-
async def cb_imgmodel(cb: CallbackQuery) -> None:
|
| 3775 |
-
if cb.message is None:
|
| 3776 |
-
await _safe_callback_answer(cb, "Ошибка: сообщение не найдено.", show_alert=True)
|
| 3777 |
-
return
|
| 3778 |
-
parts = cb.data.split(":", 2)
|
| 3779 |
-
if len(parts) < 3 or parts[1] != "set":
|
| 3780 |
-
await _safe_callback_answer(cb, "Некорректный формат запроса.", show_alert=True)
|
| 3781 |
-
return
|
| 3782 |
-
state = get_state(cb.message.chat.id)
|
| 3783 |
-
model_id = parts[2]
|
| 3784 |
-
# Раньше здесь ещё проверялось членство в HF_IMAGE_MODEL_CACHE — избыточно:
|
| 3785 |
-
# каталог для клавиатуры и так всегда строится 1:1 из HF_IMAGE_MODELS (см.
|
| 3786 |
-
# _hf_model_catalog выше), отдельной проверки по кэшу не требовалось никогда.
|
| 3787 |
-
if not model_id or model_id not in HF_IMAGE_MODELS:
|
| 3788 |
-
await _safe_callback_answer(cb, "Модель больше недоступна.", show_alert=True)
|
| 3789 |
-
return
|
| 3790 |
-
requester_id = cb.from_user.id if cb.from_user else None
|
| 3791 |
-
if not await _is_privileged_in_chat(cb.message.chat.type, cb.message.chat.id, requester_id):
|
| 3792 |
-
await _safe_callback_answer(cb, "В группе менять модель может только администратор чата.", show_alert=True)
|
| 3793 |
-
return
|
| 3794 |
-
state["image_model"] = model_id
|
| 3795 |
-
mark_state_dirty(cb.message.chat.id)
|
| 3796 |
-
meta = HF_IMAGE_MODELS.get(model_id, {})
|
| 3797 |
-
await _safe_callback_answer(cb, f"Выбрана модель {_image_model_label(model_id)}")
|
| 3798 |
-
await _tg_call(
|
| 3799 |
-
cb.message.edit_text,
|
| 3800 |
-
f"<b>Текущая модель:</b> {_html_mod.escape(meta.get('name', model_id))}\n"
|
| 3801 |
-
f"{_html_mod.escape(meta.get('desc', ''))}\n\n"
|
| 3802 |
-
f"Выберите другую модель ниже:",
|
| 3803 |
-
reply_markup=_imgmodel_keyboard(model_id),
|
| 3804 |
-
parse_mode=ParseMode.HTML,
|
| 3805 |
-
)
|
| 3806 |
-
|
| 3807 |
-
|
| 3808 |
@dp.message(Command("draw"))
|
| 3809 |
async def cmd_draw(message: Message) -> None:
|
| 3810 |
prompt = message.text.partition(" ")[2].strip() if message.text else ""
|
|
@@ -4662,7 +4585,6 @@ async def _webhook_startup() -> None:
|
|
| 4662 |
BotCommand(command="reset", description="Очистить историю диалога"),
|
| 4663 |
BotCommand(command="draw", description="Нарисовать изображение по описанию"),
|
| 4664 |
BotCommand(command="tts", description="Озвучить текст"),
|
| 4665 |
-
BotCommand(command="imgmodel", description="Выбрать модель для генерации изображений"),
|
| 4666 |
]
|
| 4667 |
|
| 4668 |
async def try_setup():
|
|
|
|
| 34 |
import aiohttp
|
| 35 |
import sentry_sdk
|
| 36 |
import uvicorn
|
| 37 |
+
from aiogram import Bot, Dispatcher
|
| 38 |
from aiogram.client.telegram import TelegramAPIServer
|
| 39 |
from aiogram.exceptions import TelegramEntityTooLarge
|
| 40 |
from aiogram.enums import ChatType, ParseMode
|
|
|
|
| 936 |
# остаётся обычным dict, никакой валидации здесь не добавляется — это только
|
| 937 |
# документация формы для статических проверок типов и читаемости.
|
| 938 |
class ChatState(TypedDict, total=False):
|
|
|
|
| 939 |
history: list[dict[str, Any]]
|
| 940 |
quota: dict[str, Any]
|
| 941 |
ctx: "deque[str]"
|
|
|
|
| 1198 |
StorageConfig,
|
| 1199 |
_chat_storage_key,
|
| 1200 |
_serialize_chat_state,
|
|
|
|
| 1201 |
_current_quota_day,
|
| 1202 |
_upstash_request as _lumen_upstash_request,
|
| 1203 |
_upstash_set as _lumen_upstash_set,
|
|
|
|
| 1394 |
log.warning("[quota] Failed to save global quota: %s", exc)
|
| 1395 |
|
| 1396 |
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: до сих пор каждый формат-дрейф персистентного
|
| 1397 |
+
# снимка чата (слияние gemini_history/or_history в единую history) обнаруживался
|
| 1398 |
+
# в _restore_single_chat ad hoc проверками "есть ли такой-то ключ в JSON" —
|
| 1399 |
+
# рабочий, но накопительный подход: с каждым новым изменением формата туда
|
| 1400 |
+
# добавлялась ещё одна ветка "если ключа нет — значит старая запись".
|
| 1401 |
+
# CHAT_STATE_SCHEMA_VERSION (импортирован из lumen_state_storage.py вместе с
|
| 1402 |
+
# _serialize_chat_state — см. блок импорта в начале секции "хранение состояния и
|
| 1403 |
+
# квот") делает следующую подобную миграцию однозначной: новый код сможет
|
| 1404 |
+
# проверять `s.get("schema_version", 0)` одним явным числом вместо повторного
|
| 1405 |
+
# гадания по присутствию ключей. Существующие персистентные записи (сделанные до
|
| 1406 |
+
# введения этого поля) не имеют "schema_version" вообще — они естественно
|
| 1407 |
+
# трактуются как версия 0 и продолжают проходить через уже отлаженные эвристики
|
| 1408 |
+
# ниже без каких-либо изменений в их поведении (это поле — задел на будущее, а
|
| 1409 |
+
# не ретроактивная миграция уже написанной логики).
|
|
|
|
| 1410 |
|
| 1411 |
def _restore_single_chat(cid: int, s: dict[str, Any]) -> None:
|
| 1412 |
"""Разворачивает сериализованный снимок одного чата (см. _serialize_chat_state)
|
| 1413 |
обратно в chat_state[cid] — общая логика между новым per-chat форматом чтения
|
| 1414 |
и одноразовой миграцией из старо��о общего блоба (см. load_state_from_disk).
|
| 1415 |
|
| 1416 |
+
Поля "gemini_model"/"openrouter_text_model"/"chat_provider"/"image_model" из
|
| 1417 |
+
старых записей (созданных до перехода на автоматический роутер для текста и,
|
| 1418 |
+
позже, для генерации изображений — см. README, "Автоматический выбор модели")
|
| 1419 |
+
намеренно нигде ниже не читаются — они устарели и больше ни на что не влияют.
|
| 1420 |
|
| 1421 |
schema_version (см. CHAT_STATE_SCHEMA_VERSION выше) в самих записях, читаемых
|
| 1422 |
+
здесь, пока ни на что не влияет — существующая миграция (history/gemini_history)
|
| 1423 |
+
уже надёжно определяется по присутствию конкретных ключей, и это не нужно
|
| 1424 |
+
менять задним числом. Поле — задел на СЛЕДУЮЩИЙ формат-дрейф: тогда новую
|
| 1425 |
+
ветку можно будет добавить как `if s.get("schema_version", 0) < N`, а не
|
| 1426 |
+
подбирать очередную эвристику по ключам, как приходилось делать для миграции
|
| 1427 |
+
ниже."""
|
| 1428 |
schema_version = s.get("schema_version", 0)
|
| 1429 |
log.debug('[state] Restoring chat %s (schema_version=%s)', cid, schema_version)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1430 |
raw_media = s.get("recent_media_ids", {})
|
| 1431 |
if isinstance(raw_media, dict):
|
| 1432 |
media_buckets = {
|
|
|
|
| 1449 |
if len(history) > SHARED_HISTORY_MAX_LEN:
|
| 1450 |
history = history[-SHARED_HISTORY_MAX_LEN:]
|
| 1451 |
chat_state[cid] = {
|
|
|
|
| 1452 |
"history": history,
|
| 1453 |
"quota": s.get("quota", {}),
|
| 1454 |
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
|
|
|
| 1678 |
def get_state(chat_id: int) -> dict[str, Any]:
|
| 1679 |
if chat_id not in chat_state:
|
| 1680 |
chat_state[chat_id] = {
|
|
|
|
| 1681 |
"history": [],
|
| 1682 |
"quota": {},
|
| 1683 |
"ctx": deque(maxlen=MAX_CHAT_HISTORY_LEN),
|
|
|
|
| 1690 |
_mark_new_chat_id(chat_id)
|
| 1691 |
else:
|
| 1692 |
chat_state[chat_id]["last_activity"] = time.monotonic()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1693 |
if len(chat_state) > MAX_CHAT_LIMIT:
|
| 1694 |
_prune_old_chats()
|
| 1695 |
return chat_state[chat_id]
|
|
|
|
| 1763 |
# генерация изображений (pollinations)
|
| 1764 |
#
|
| 1765 |
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА (разбиение bot.py на модули): вся эта секция (каталог
|
| 1766 |
+
# моделей Pollinations, автоматический выбор модели по промпту, сам вызов
|
| 1767 |
+
# Pollinations.ai) вынесена в lumen_images.py — не пишет в chat_state/GLOBAL_QUOTA,
|
| 1768 |
+
# не зовёт Telegram API и не зависит от глобальных bot/client, самый изолированный
|
| 1769 |
+
# кандидат из пяти намеченных. Единственное отличие от прежнего кода:
|
| 1770 |
+
# _pollinations_generate/_hf_text_to_image теперь принимают уже готовую aiohttp-сессию
|
| 1771 |
+
# параметром (см. докстринг модуля) — раньше сессия получалась неявно через
|
| 1772 |
+
# _get_http_session() внутри самой функции, что означало бы либо тянуть этот геттер
|
| 1773 |
+
# в новый модуль, либо заводить там свой отдельный источник сессий; вызывающий код
|
| 1774 |
+
# (inline_draw ниже) теперь сам получает сессию и передаёт её. Публичные имена и
|
| 1775 |
+
# остальное поведение не изменились.
|
| 1776 |
from lumen_images import (
|
| 1777 |
DEFAULT_HF_IMAGE_MODEL,
|
| 1778 |
HF_IMAGE_MODELS,
|
| 1779 |
+
_pick_image_model,
|
|
|
|
| 1780 |
_hf_text_to_image,
|
| 1781 |
_image_model_label,
|
| 1782 |
)
|
| 1783 |
|
| 1784 |
+
# DEFAULT_HF_IMAGE_MODEL больше не читается напрямую нигде в остальном коде bot.py
|
| 1785 |
+
# (используется только внутри самой _pick_image_model в lumen_images.py) — но
|
| 1786 |
+
# остаётся нужен как `bot.DEFAULT_HF_IMAGE_MODEL` для существующих тестов. См.
|
| 1787 |
+
# пояснение про __all__ у первого блока (lumen_formatting) в начале файла.
|
| 1788 |
+
__all__ += ["DEFAULT_HF_IMAGE_MODEL"]
|
| 1789 |
|
| 1790 |
# метаданные и скачивание медиа
|
| 1791 |
|
|
|
|
| 3649 |
"Я отвечаю на вопросы с поиском в реальном времени, разбираю фото, видео, аудио и документы, рисую изображения по описанию и озвучиваю текст голосом.\n\n"
|
| 3650 |
"<b>Команды</b>\n"
|
| 3651 |
"/draw [описание] — нарисовать изображение\n"
|
| 3652 |
+
"/tts [текст] — озвучить текст голосом\n\n"
|
|
|
|
| 3653 |
"Рисовать и озвучивать можно и просто словами, без команд — например «нарисуй кота» или «озвучь это».\n\n"
|
| 3654 |
"<b>TikTok</b>\n"
|
| 3655 |
"Просто пришлите ссылку — скачаю видео или фото без водяных знаков.\n\n"
|
|
|
|
| 3661 |
status = await _tg_call(message.reply, "Генерирую изображение")
|
| 3662 |
try:
|
| 3663 |
session = await _get_http_session()
|
| 3664 |
+
# УБРАНО (аудит техдолга, 19 августа 2026): раньше основная модель бралась
|
| 3665 |
+
# из ручного выбора пользователя (/imgmodel, команда удалена — см. README,
|
| 3666 |
+
# "Автоматический выбор модели"). Теперь _pick_image_model сама подбирает
|
| 3667 |
+
# модель по содержимому промпта на каждый вызов, без какого-либо состояния
|
| 3668 |
+
# чата — тот же принцип, что уже применяется к тексту (_build_route).
|
| 3669 |
+
primary_model = _pick_image_model(prompt)
|
| 3670 |
+
|
| 3671 |
+
# Фолбэк-цепочка: пробуем сначала подобранную модель,
|
| 3672 |
# при ошибке переключаемся на следующие по порядку из HF_IMAGE_MODELS
|
| 3673 |
all_model_ids = list(HF_IMAGE_MODELS.keys())
|
| 3674 |
fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
|
|
|
|
| 3724 |
else:
|
| 3725 |
# Сырой текст ошибки провайдера пользователю не показываем (см. log.exception
|
| 3726 |
# выше) — та же логика, что и в остальных обработчиках ошибок бота.
|
| 3727 |
+
user_err = "Ошибка генерации изображения. Попробуйте ещё раз или переформулируйте описание."
|
| 3728 |
await _edit_message_quietly(status, user_err)
|
| 3729 |
|
| 3730 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3731 |
@dp.message(Command("draw"))
|
| 3732 |
async def cmd_draw(message: Message) -> None:
|
| 3733 |
prompt = message.text.partition(" ")[2].strip() if message.text else ""
|
|
|
|
| 4585 |
BotCommand(command="reset", description="Очистить историю диалога"),
|
| 4586 |
BotCommand(command="draw", description="Нарисовать изображение по описанию"),
|
| 4587 |
BotCommand(command="tts", description="Озвучить текст"),
|
|
|
|
| 4588 |
]
|
| 4589 |
|
| 4590 |
async def try_setup():
|
lumen_images.py
CHANGED
|
@@ -19,16 +19,23 @@ HTTP-соединениями было бы речь не о разделени
|
|
| 19 |
Публичные имена и поведение не изменились относительно прежнего кода внутри bot.py — кроме
|
| 20 |
добавленного параметра `session`, который раньше был получен неявно через `_get_http_session()`
|
| 21 |
внутри самой `_pollinations_generate`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
import os
|
|
|
|
| 27 |
import urllib.parse
|
| 28 |
from typing import Any
|
| 29 |
|
| 30 |
import aiohttp
|
| 31 |
-
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
| 32 |
|
| 33 |
DEFAULT_HF_IMAGE_MODEL = os.getenv("HF_IMAGE_MODEL", "flux").strip()
|
| 34 |
|
|
@@ -55,44 +62,49 @@ HF_IMAGE_MODELS: dict[str, dict[str, Any]] = {
|
|
| 55 |
},
|
| 56 |
}
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
async def _pollinations_generate(session: aiohttp.ClientSession, model_name: str, prompt: str) -> bytes:
|
|
|
|
| 19 |
Публичные имена и поведение не изменились относительно прежнего кода внутри bot.py — кроме
|
| 20 |
добавленного параметра `session`, который раньше был получен неявно через `_get_http_session()`
|
| 21 |
внутри самой `_pollinations_generate`.
|
| 22 |
+
|
| 23 |
+
УБРАНО (аудит техдолга, 19 августа 2026): команда `/imgmodel` и ручной выбор модели через
|
| 24 |
+
inline-клавиатуру — тот же класс изменения, что уже был сделан для текстового провайдера/
|
| 25 |
+
модели (см. "Автоматический выбор модели" в README): пользователь никогда явно не выбирает
|
| 26 |
+
модель, роутер выбирает сам на каждый запрос (`_pick_image_model` ниже). `_hf_model_catalog`/
|
| 27 |
+
`_imgmodel_keyboard` были нужны ТОЛЬКО для этой клавиатуры — убраны вместе с ней как мёртвый
|
| 28 |
+
код, а не оставлены "на будущее".
|
| 29 |
"""
|
| 30 |
|
| 31 |
from __future__ import annotations
|
| 32 |
|
| 33 |
import os
|
| 34 |
+
import re
|
| 35 |
import urllib.parse
|
| 36 |
from typing import Any
|
| 37 |
|
| 38 |
import aiohttp
|
|
|
|
| 39 |
|
| 40 |
DEFAULT_HF_IMAGE_MODEL = os.getenv("HF_IMAGE_MODEL", "flux").strip()
|
| 41 |
|
|
|
|
| 62 |
},
|
| 63 |
}
|
| 64 |
|
| 65 |
+
# ── Автоматический выбор модели генерации по содержимому промпта ──
|
| 66 |
+
# Тот же принцип, что и у текстового роутера (_looks_like_heavy_query/
|
| 67 |
+
# _looks_like_freshness_query в lumen_router_config.py): грубая эвристика по
|
| 68 |
+
# ключевым словам без обращения к LLM — отдельный классифицирующий вызов модели
|
| 69 |
+
# стоил бы дороже, чем просто попробовать разумный дефолт и дать сработать
|
| 70 |
+
# существующей fallback-цепочке (см. inline_draw в bot.py) при неудаче/плохом
|
| 71 |
+
# результате. Порядок проверки — от самых специфичных категорий к общей: аниме/
|
| 72 |
+
# фэнтези/реализм/черновик — явные сигналы жанра, при их отсутствии остаётся
|
| 73 |
+
# DEFAULT_HF_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*|"
|
| 77 |
+
r"орк\w*|фея|фей\b|замок\w*|рыцар\w*|fantasy|concept\s?art|dragon|wizard|elf|elves",
|
| 78 |
+
re.IGNORECASE,
|
| 79 |
+
)
|
| 80 |
+
_REALISM_RE = re.compile(
|
| 81 |
+
r"фотореалистичн\w*|гиперреалистичн\w*|реалистичн\w*|как\s+(на\s+)?фото|фотограф\w*|"
|
| 82 |
+
r"portrait|realistic|photorealistic|hyperrealistic",
|
| 83 |
+
re.IGNORECASE,
|
| 84 |
+
)
|
| 85 |
+
_QUICK_RE = re.compile(r"побыстрее|быстро|набросок|черновик|скетч|эскиз|draft|sketch|quick", re.IGNORECASE)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _pick_image_model(prompt: str) -> str:
|
| 89 |
+
"""Заменяет ручной выбор через убранную команду /imgmodel — см. докстринг модуля
|
| 90 |
+
и README, раздел "Автоматический выбор модели". Проверки НЕ взаимоисключающие по
|
| 91 |
+
смыслу (промпт может упоминать и аниме, и дракона), поэтому порядок фиксирован:
|
| 92 |
+
аниме/манга — самый визуально узнаваемый и однозначный стиль, проверяется первым;
|
| 93 |
+
фэнтези/концепт-арт — вторая по специфичности категория; фотореализм — просьба
|
| 94 |
+
"как фото" достаточно однозначна сама по себе; черновик/скетч — про скорость, а
|
| 95 |
+
не стиль, поэтому последний перед дефолтом (стилевые сигналы важнее просьбы
|
| 96 |
+
"побыстрее", если оба есть в одном промпте)."""
|
| 97 |
+
if not prompt:
|
| 98 |
+
return DEFAULT_HF_IMAGE_MODEL
|
| 99 |
+
if _ANIME_RE.search(prompt):
|
| 100 |
+
return "flux-anime"
|
| 101 |
+
if _FANTASY_RE.search(prompt):
|
| 102 |
+
return "dreamshaper"
|
| 103 |
+
if _REALISM_RE.search(prompt):
|
| 104 |
+
return "flux-realism"
|
| 105 |
+
if _QUICK_RE.search(prompt):
|
| 106 |
+
return "turbo"
|
| 107 |
+
return DEFAULT_HF_IMAGE_MODEL
|
| 108 |
|
| 109 |
|
| 110 |
async def _pollinations_generate(session: aiohttp.ClientSession, model_name: str, prompt: str) -> bytes:
|
lumen_state_storage.py
CHANGED
|
@@ -42,8 +42,6 @@ from datetime import datetime
|
|
| 42 |
from pathlib import Path
|
| 43 |
from typing import Any
|
| 44 |
|
| 45 |
-
from lumen_images import DEFAULT_HF_IMAGE_MODEL
|
| 46 |
-
|
| 47 |
log = logging.getLogger("bot")
|
| 48 |
|
| 49 |
CHAT_STATE_SCHEMA_VERSION = 1
|
|
@@ -146,12 +144,15 @@ def _serialize_chat_state(state: dict[str, Any]) -> dict[str, Any]:
|
|
| 146 |
"openrouter_text_model"/"chat_provider" здесь БОЛЬШЕ НЕ хранятся — раньше это
|
| 147 |
был явный выбор пользователя через /model и /provider, теперь провайдер и
|
| 148 |
модель подбираются заново на каждое сообщение, хранить их per-chat незачем.
|
| 149 |
-
|
| 150 |
-
из
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
bot.py, там нет ни одной попытки их прочитать."""
|
| 152 |
return {
|
| 153 |
"schema_version": CHAT_STATE_SCHEMA_VERSION,
|
| 154 |
-
"image_model": state.get("image_model", DEFAULT_HF_IMAGE_MODEL),
|
| 155 |
"history": list(state.get("history", [])),
|
| 156 |
"quota": state.get("quota", {}),
|
| 157 |
"recent_media_ids": {
|
|
@@ -160,19 +161,6 @@ def _serialize_chat_state(state: dict[str, Any]) -> dict[str, Any]:
|
|
| 160 |
}
|
| 161 |
|
| 162 |
|
| 163 |
-
def _normalize_legacy_image_model_id(image_model: Any) -> Any:
|
| 164 |
-
"""Снимает старую приставку "pollinations:" с персистентного image_model, если
|
| 165 |
-
она там осталась от записи, сделанной до её удаления из HF_IMAGE_MODELS (см.
|
| 166 |
-
ponytail-audit) — единственный провайдер генерации изображений и так один,
|
| 167 |
-
приставка была лишней, но существующие персистентные записи чатов её ещё
|
| 168 |
-
содержат. Без этой миграции такой чат откатился бы на DEFAULT_HF_IMAGE_MODEL,
|
| 169 |
-
молча потеряв выбор пользователя (см. вызовы в _restore_single_chat/get_state
|
| 170 |
-
в bot.py)."""
|
| 171 |
-
if isinstance(image_model, str) and image_model.startswith("pollinations:"):
|
| 172 |
-
return image_model.split(":", 1)[1]
|
| 173 |
-
return image_model
|
| 174 |
-
|
| 175 |
-
|
| 176 |
# ─────────────────── дата для сброса дневной квоты ───────────────────
|
| 177 |
|
| 178 |
def _current_quota_day() -> str:
|
|
|
|
| 42 |
from pathlib import Path
|
| 43 |
from typing import Any
|
| 44 |
|
|
|
|
|
|
|
| 45 |
log = logging.getLogger("bot")
|
| 46 |
|
| 47 |
CHAT_STATE_SCHEMA_VERSION = 1
|
|
|
|
| 144 |
"openrouter_text_model"/"chat_provider" здесь БОЛЬШЕ НЕ хранятся — раньше это
|
| 145 |
был явный выбор пользователя через /model и /provider, теперь провайдер и
|
| 146 |
модель подбираются заново на каждое сообщение, хранить их per-chat незачем.
|
| 147 |
+
"image_model" по той же причине убран отсюда 19 августа 2026 — генерация
|
| 148 |
+
изображений (см. README, "Автоматический выбор модели") тоже перешла на
|
| 149 |
+
подбор модели заново на каждый вызов (`_pick_image_model` в lumen_images.py)
|
| 150 |
+
вместо персистентного выбора через удалённую команду /imgmodel. Старые
|
| 151 |
+
персистентные записи, где эти поля ещё есть (созданные до соответствующих
|
| 152 |
+
изменений), просто тихо игнорируются при чтении — см. _restore_single_chat в
|
| 153 |
bot.py, там нет ни одной попытки их прочитать."""
|
| 154 |
return {
|
| 155 |
"schema_version": CHAT_STATE_SCHEMA_VERSION,
|
|
|
|
| 156 |
"history": list(state.get("history", [])),
|
| 157 |
"quota": state.get("quota", {}),
|
| 158 |
"recent_media_ids": {
|
|
|
|
| 161 |
}
|
| 162 |
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
# ─────────────────── дата для сброса дневной квоты ───────────────────
|
| 165 |
|
| 166 |
def _current_quota_day() -> str:
|
test_bot.py
CHANGED
|
@@ -673,63 +673,6 @@ def test_upstash_get_parses_result_field():
|
|
| 673 |
bot.UPSTASH_REDIS_REST_TOKEN = ""
|
| 674 |
|
| 675 |
|
| 676 |
-
# ─────────────── миграция image_model со старого формата "pollinations:X" ───────────────
|
| 677 |
-
# РЕГРЕССИЯ, найденная при /code-review: ponytail-audit убрал приставку "pollinations:"
|
| 678 |
-
# из ключей HF_IMAGE_MODELS (единственный провайдер и так один). Без миграции это молча
|
| 679 |
-
# сбрасывало бы image_model существующих персистентных чатов на DEFAULT_HF_IMAGE_MODEL —
|
| 680 |
-
# реальный выбор пользователя (например "pollinations:turbo") терялся бы без предупреждения,
|
| 681 |
-
# просто потому что старое значение больше не совпадает ни с одним ключом HF_IMAGE_MODELS.
|
| 682 |
-
|
| 683 |
-
def test_normalize_legacy_image_model_id_strips_old_prefix():
|
| 684 |
-
assert bot._normalize_legacy_image_model_id("pollinations:turbo") == "turbo"
|
| 685 |
-
assert bot._normalize_legacy_image_model_id("pollinations:flux-anime") == "flux-anime"
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
def test_normalize_legacy_image_model_id_passthrough_for_current_format():
|
| 689 |
-
assert bot._normalize_legacy_image_model_id("turbo") == "turbo"
|
| 690 |
-
assert bot._normalize_legacy_image_model_id("flux") == "flux"
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
def test_normalize_legacy_image_model_id_passthrough_for_non_string():
|
| 694 |
-
# Защита от неожиданных типов в персистентных данных — не должно падать.
|
| 695 |
-
assert bot._normalize_legacy_image_model_id(None) is None
|
| 696 |
-
assert bot._normalize_legacy_image_model_id(123) == 123
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
def test_restore_single_chat_migrates_legacy_pollinations_image_model():
|
| 700 |
-
cid = 999901
|
| 701 |
-
try:
|
| 702 |
-
bot._restore_single_chat(cid, {"image_model": "pollinations:turbo", "history": []})
|
| 703 |
-
assert bot.chat_state[cid]["image_model"] == "turbo"
|
| 704 |
-
finally:
|
| 705 |
-
bot.chat_state.pop(cid, None)
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
def test_restore_single_chat_falls_back_to_default_for_truly_unknown_image_model():
|
| 709 |
-
cid = 999902
|
| 710 |
-
try:
|
| 711 |
-
bot._restore_single_chat(cid, {"image_model": "some-removed-model-nobody-heard-of", "history": []})
|
| 712 |
-
assert bot.chat_state[cid]["image_model"] == bot.DEFAULT_HF_IMAGE_MODEL
|
| 713 |
-
finally:
|
| 714 |
-
bot.chat_state.pop(cid, None)
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
def test_get_state_migrates_legacy_pollinations_image_model_in_memory():
|
| 718 |
-
# Защита в глубину (см. get_state) — на случай, если в chat_state окажется
|
| 719 |
-
# старое значение уже ПОСЛЕ восстановления (например, вручную отредактированные
|
| 720 |
-
# персистентные данные), а не только на пути через _restore_single_chat.
|
| 721 |
-
cid = 999903
|
| 722 |
-
try:
|
| 723 |
-
bot.chat_state[cid] = {
|
| 724 |
-
"image_model": "pollinations:flux-realism", "history": [], "quota": {},
|
| 725 |
-
"recent_media_ids": {}, "last_activity": 0.0,
|
| 726 |
-
}
|
| 727 |
-
state = bot.get_state(cid)
|
| 728 |
-
assert state["image_model"] == "flux-realism"
|
| 729 |
-
finally:
|
| 730 |
-
bot.chat_state.pop(cid, None)
|
| 731 |
-
|
| 732 |
-
|
| 733 |
# ─────────────────────────── _save_chat_to_storage/_delete_chat_storage возвращают bool ───────────────────────────
|
| 734 |
|
| 735 |
def test_save_chat_to_storage_returns_true_on_success(tmp_path):
|
|
@@ -2370,31 +2313,81 @@ def test_reset_quota_if_new_day_throttles_repeated_calls():
|
|
| 2370 |
bot._last_quota_check_monotonic = original_throttle
|
| 2371 |
|
| 2372 |
|
| 2373 |
-
# ───────────────────
|
| 2374 |
-
#
|
| 2375 |
-
#
|
| 2376 |
-
#
|
| 2377 |
-
# с
|
| 2378 |
-
|
| 2379 |
-
|
| 2380 |
-
|
| 2381 |
-
|
| 2382 |
-
|
| 2383 |
-
|
| 2384 |
-
|
| 2385 |
-
|
| 2386 |
-
|
| 2387 |
-
|
| 2388 |
-
|
| 2389 |
-
|
| 2390 |
-
|
| 2391 |
-
|
| 2392 |
-
|
| 2393 |
-
|
| 2394 |
-
|
| 2395 |
-
assert bot.
|
| 2396 |
-
|
| 2397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2398 |
|
| 2399 |
# ─────────────────── schema_version персистентного снимка чата (аудит техдолга) ───────────────────
|
| 2400 |
|
|
|
|
| 673 |
bot.UPSTASH_REDIS_REST_TOKEN = ""
|
| 674 |
|
| 675 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
# ─────────────────────────── _save_chat_to_storage/_delete_chat_storage возвращают bool ───────────────────────────
|
| 677 |
|
| 678 |
def test_save_chat_to_storage_returns_true_on_success(tmp_path):
|
|
|
|
| 2313 |
bot._last_quota_check_monotonic = original_throttle
|
| 2314 |
|
| 2315 |
|
| 2316 |
+
# ─────────────────── авто-роутинг генерации изображений (аудит техдолга, 19 августа 2026) ───────────────────
|
| 2317 |
+
# Команда /imgmodel и ручной выбор модели убраны целиком — тот же переход, что уже
|
| 2318 |
+
# был сделан для текстового провайдера/модели (см. README, "Автоматический выбор
|
| 2319 |
+
# модели"). _pick_image_model заменяет собой ручной выбор: подбирает модель по
|
| 2320 |
+
# содержимому промпта на каждый вызов, без персистентного состояния чата.
|
| 2321 |
+
|
| 2322 |
+
def test_pick_image_model_detects_anime():
|
| 2323 |
+
assert bot._pick_image_model("нарисуй девушку в стиле аниме") == "flux-anime"
|
| 2324 |
+
assert bot._pick_image_model("draw a chibi character") == "flux-anime"
|
| 2325 |
+
|
| 2326 |
+
|
| 2327 |
+
def test_pick_image_model_detects_fantasy():
|
| 2328 |
+
assert bot._pick_image_model("нарисуй дракона в фэнтезийном замке") == "dreamshaper"
|
| 2329 |
+
assert bot._pick_image_model("concept art of an elf wizard") == "dreamshaper"
|
| 2330 |
+
|
| 2331 |
+
|
| 2332 |
+
def test_pick_image_model_detects_realism():
|
| 2333 |
+
assert bot._pick_image_model("сделай фотореалистичный портрет кота") == "flux-realism"
|
| 2334 |
+
assert bot._pick_image_model("realistic photo of a mountain") == "flux-realism"
|
| 2335 |
+
|
| 2336 |
+
|
| 2337 |
+
def test_pick_image_model_detects_quick_draft():
|
| 2338 |
+
assert bot._pick_image_model("быстрый набросок логотипа") == "turbo"
|
| 2339 |
+
|
| 2340 |
+
|
| 2341 |
+
def test_pick_image_model_falls_back_to_default_for_generic_prompt():
|
| 2342 |
+
assert bot._pick_image_model("космическая станция на орбите Земли") == bot.DEFAULT_HF_IMAGE_MODEL
|
| 2343 |
+
assert bot._pick_image_model("") == bot.DEFAULT_HF_IMAGE_MODEL
|
| 2344 |
+
|
| 2345 |
+
|
| 2346 |
+
def test_pick_image_model_style_keyword_wins_over_quick_keyword():
|
| 2347 |
+
# Стилевой сигнал важнее просьбы "побыстрее", если оба есть в одном промпте —
|
| 2348 |
+
# см. докстринг _pick_image_model про порядок проверок.
|
| 2349 |
+
assert bot._pick_image_model("быстро нарисуй аниме-девушку") == "flux-anime"
|
| 2350 |
+
|
| 2351 |
+
|
| 2352 |
+
def test_imgmodel_command_and_callback_removed():
|
| 2353 |
+
# Регрессия на сам факт удаления команды — не должно остаться ни обработчика,
|
| 2354 |
+
# ни клавиатуры, ни каталога, которые её обслуживали.
|
| 2355 |
+
assert not hasattr(bot, "cmd_imgmodel")
|
| 2356 |
+
assert not hasattr(bot, "cb_imgmodel")
|
| 2357 |
+
assert not hasattr(bot, "_imgmodel_keyboard")
|
| 2358 |
+
assert not hasattr(bot, "_hf_model_catalog")
|
| 2359 |
+
|
| 2360 |
+
|
| 2361 |
+
def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
|
| 2362 |
+
chat_id = 999430
|
| 2363 |
+
captured_model = []
|
| 2364 |
+
|
| 2365 |
+
async def fake_hf_text_to_image(session, model_id, prompt):
|
| 2366 |
+
captured_model.append(model_id)
|
| 2367 |
+
return b"\x89PNG fake bytes"
|
| 2368 |
+
|
| 2369 |
+
incoming = _FakeIncomingMessage(chat_id)
|
| 2370 |
+
incoming.message_id = 1
|
| 2371 |
+
|
| 2372 |
+
class _FakePhotoBot:
|
| 2373 |
+
async def send_photo(self, **kwargs):
|
| 2374 |
+
return SimpleNamespace()
|
| 2375 |
+
|
| 2376 |
+
original_hf = bot._hf_text_to_image
|
| 2377 |
+
original_bot_obj = bot.bot
|
| 2378 |
+
bot._hf_text_to_image = fake_hf_text_to_image
|
| 2379 |
+
bot.bot = _FakePhotoBot()
|
| 2380 |
+
try:
|
| 2381 |
+
asyncio.run(bot.inline_draw(incoming, "нарисуй девушку в стиле аниме на пляже"))
|
| 2382 |
+
assert captured_model == ["flux-anime"]
|
| 2383 |
+
# get_state не должен был получить ключ image_model — авто-роутинг не
|
| 2384 |
+
# завязан на состояние чата вообще.
|
| 2385 |
+
assert "image_model" not in bot.get_state(chat_id)
|
| 2386 |
+
finally:
|
| 2387 |
+
bot._hf_text_to_image = original_hf
|
| 2388 |
+
bot.bot = original_bot_obj
|
| 2389 |
+
bot.chat_state.pop(chat_id, None)
|
| 2390 |
+
|
| 2391 |
|
| 2392 |
# ─────────────────── schema_version персистентного снимка чата (аудит техдолга) ───────────────────
|
| 2393 |
|
test_lumen_images.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
test_lumen_images.py — юнит-тесты на lumen_images.py: автоматический выбор модели
|
| 3 |
+
генерации изображений по содержимому промпта (_pick_image_model), заменивший ручной
|
| 4 |
+
выбор через убранную команду /imgmodel (см. README, "Автоматический выбор модели").
|
| 5 |
+
|
| 6 |
+
lumen_images.py не зависит от Telegram/рантайм-состояния бота (тот же принцип, что и
|
| 7 |
+
у lumen_formatting.py/lumen_router_config.py — см. их тестовые файлы), поэтому
|
| 8 |
+
тестируется здесь напрямую (import lumen_images), без импорта bot.py.
|
| 9 |
+
|
| 10 |
+
Запуск:
|
| 11 |
+
pytest test_lumen_images.py -v
|
| 12 |
+
"""
|
| 13 |
+
import lumen_images
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ─────────────────────────── _pick_image_model ───────────────────────────
|
| 17 |
+
|
| 18 |
+
def test_pick_image_model_anime():
|
| 19 |
+
assert lumen_images._pick_image_model("нарисуй девушку в стиле аниме") == "flux-anime"
|
| 20 |
+
assert lumen_images._pick_image_model("draw a chibi character") == "flux-anime"
|
| 21 |
+
assert lumen_images._pick_image_model("manga style portrait") == "flux-anime"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_pick_image_model_fantasy():
|
| 25 |
+
assert lumen_images._pick_image_model("нарисуй дракона в фэнтезийном замке") == "dreamshaper"
|
| 26 |
+
assert lumen_images._pick_image_model("concept art of an elf wizard") == "dreamshaper"
|
| 27 |
+
assert lumen_images._pick_image_model("рыцарь на фоне волшебного леса") == "dreamshaper"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_pick_image_model_realism():
|
| 31 |
+
assert lumen_images._pick_image_model("сделай фотореалистичный портрет кота") == "flux-realism"
|
| 32 |
+
assert lumen_images._pick_image_model("realistic photo of a mountain") == "flux-realism"
|
| 33 |
+
assert lumen_images._pick_image_model("нарисуй кота как на фото") == "flux-realism"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_pick_image_model_quick_draft():
|
| 37 |
+
assert lumen_images._pick_image_model("быстрый набросок логотипа") == "turbo"
|
| 38 |
+
assert lumen_images._pick_image_model("quick sketch of a car") == "turbo"
|
| 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_HF_IMAGE_MODEL
|
| 43 |
+
assert lumen_images._pick_image_model("кот на подоконнике") == lumen_images.DEFAULT_HF_IMAGE_MODEL
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_pick_image_model_empty_prompt_returns_default():
|
| 47 |
+
assert lumen_images._pick_image_model("") == lumen_images.DEFAULT_HF_IMAGE_MODEL
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_pick_image_model_style_keyword_wins_over_quick_keyword():
|
| 51 |
+
# Стилевой сигнал важнее просьбы "побыстрее", если оба есть в одном промпте —
|
| 52 |
+
# аниме проверяется раньше черновика/скетча (см. докстринг _pick_image_model).
|
| 53 |
+
assert lumen_images._pick_image_model("быстро нарисуй аниме-девушку") == "flux-anime"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_pick_image_model_case_insensitive():
|
| 57 |
+
assert lumen_images._pick_image_model("АНИМЕ ДЕВУШКА") == "flux-anime"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_pick_image_model_result_always_a_known_model():
|
| 61 |
+
# Регрессия на класс ошибок "эвристика вернула ID, которого нет в каталоге" —
|
| 62 |
+
# неважно, какой промпт, результат обязан быть валидным ключом HF_IMAGE_MODELS.
|
| 63 |
+
prompts = [
|
| 64 |
+
"нарисуй кота", "аниме", "фэнтези дракон", "реалистичное фото гор",
|
| 65 |
+
"быстрый скетч", "", "случайный текст без ключевых слов вообще",
|
| 66 |
+
]
|
| 67 |
+
for p in prompts:
|
| 68 |
+
assert lumen_images._pick_image_model(p) in lumen_images.HF_IMAGE_MODELS
|