SilverElixir commited on
Commit
5ca3ed2
·
unverified ·
1 Parent(s): 03d39f1

Add files via upload

Browse files
Files changed (6) hide show
  1. README.md +5 -1
  2. bot.py +50 -128
  3. lumen_images.py +51 -39
  4. lumen_state_storage.py +6 -18
  5. test_bot.py +75 -82
  6. test_lumen_images.py +68 -0
README.md CHANGED
@@ -132,7 +132,7 @@ HF Spaces не имеет прямого исходящего доступа к
132
 
133
  Ограничение "только в личных сообщениях" для `/stats`/`/logs` добавлено при код-ревью: результат команды видят ВСЕ участники чата, в котором она вызвана — если бы владелец случайно вызвал их в общем групповом чате, реальные технические детали (ID моделей и т.п.) увидели бы все, кто состоит в группе. Если вызвать в группе — бот вежливо попросит написать в личку, вместо того чтобы молча показать данные всем или молча проигнорировать команду.
134
 
135
- Команды `/model` и `/provider` (ручной выбор модели/провайдера) удалены полностью — см. раздел "Автоматический выбор модели" ниже: теперь и модель, и провайдер бот выбирает сам на каждое сообщение. `/imgmodel` (модель для генерации изображений через `/draw`) не затронута и работает как раньше — в группах менять её может только админ/создатель группы или владелец бота.
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, F
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
- # приставки "pollinations:" с image_model) обнаруживался в _restore_single_chat
1401
- # ad hoc проверками "есть ли такой-то ключ в JSON" — рабочий, но накопительный
1402
- # подход: с каждым новым изменением формата туда добавлялась ещё одна ветка
1403
- # "если ключа нет — значит старая запись". CHAT_STATE_SCHEMA_VERSION (импортирован
1404
- # из lumen_state_storage.py вместе с _serialize_chat_state/_normalize_legacy_
1405
- # image_model_id — см. блок импорта в начале секции ранение состояния и квот")
1406
- # делает следующую подобную миграцию однозначной: новый код сможет проверять
1407
- # `s.get("schema_version", 0)` одним явным числом вместо повторного гадания по
1408
- # присутствию ключей. Существующие персистентные записи (сделанные до введения
1409
- # этого поля) не имеют "schema_version" вообще они естественно трактуются как
1410
- # версия 0 и продолжают проходить через уже отлаженные эвристики ниже без
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
- здесь, пока ни на что не влияет — существующие миграции (history/gemini_history,
1425
- image_model) уже надёжно определяются по присутствию конкретных ключей, и это
1426
- не нужно менять задним числом. Поле — задел на СЛЕДУЮЩИЙ формат-дрейф: тогда
1427
- новую ветку можно будет добавить как `if s.get("schema_version", 0) < N`, а не
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, клавиатура /imgmodel, сам вызов Pollinations.ai) вынесена в
1791
- # lumen_images.py — не пишет в chat_state/GLOBAL_QUOTA, не зовёт Telegram API и не
1792
- # зависит от глобальных bot/client, самый изолированный кандидат из пяти намеченных.
1793
- # Единственное отличие от прежнего кода: _pollinations_generate/_hf_text_to_image
1794
- # теперь принимают уже готовую aiohttp-сессию параметром (см. докстринг модуля) —
1795
- # раньше сессия получалась неявно через _get_http_session() внутри самой функции,
1796
- # что означало бы либо тянуть этот геттер в новый модуль, либо заводить там свой
1797
- # отдельный источник сессий; вызывающий код (inline_draw ниже) теперь сам получает
1798
- # сессию и передаёт её. Публичные имена и остальное поведение не изменились.
 
1799
  from lumen_images import (
1800
  DEFAULT_HF_IMAGE_MODEL,
1801
  HF_IMAGE_MODELS,
1802
- _hf_model_catalog,
1803
- _imgmodel_keyboard,
1804
  _hf_text_to_image,
1805
  _image_model_label,
1806
  )
1807
 
1808
- # _hf_model_catalog не вызывается напрямую нигде в остальном коде bot.py (используется
1809
- # только внутри самой _imgmodel_keyboard в lumen_images.py) — но остаётся нужна как
1810
- # `bot._hf_model_catalog(...)` для существующих тестов. См. пояснение про __all__ у
1811
- # первого блока (lumen_formatting) в начале файла.
1812
- __all__ += ["_hf_model_catalog"]
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
- state = get_state(message.chat.id)
3690
- primary_model = state.get("image_model") or DEFAULT_HF_IMAGE_MODEL
3691
- if primary_model not in HF_IMAGE_MODELS:
3692
- primary_model = DEFAULT_HF_IMAGE_MODEL
3693
- state["image_model"] = primary_model
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 = "Ошибка генерации изображения. Попробуйте другую модель через /imgmodel."
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
- def _hf_model_catalog() -> list[dict[str, Any]]:
60
- """Список моделей генерации изображений для клавиатуры /imgmodel — прямо из
61
- HF_IMAGE_MODELS (единственный источник правды, статический список).
62
-
63
- НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше здесь был отдельный `HF_IMAGE_MODEL_CACHE`
64
- dict и `async def _hf_fetch_model_catalog()` вестигиальные остатки более
65
- раннего дизайна, когда каталог реально динамически подтягивался из HF API.
66
- Тот динамический фетч убран (см. историю — он добавлял неизвестные модели в
67
- меню), и функция давно не делает ни одного `await` внутри, а просто
68
- пересобирает список из того же самого статического HF_IMAGE_MODELS — то есть
69
- кэшировать было уже нечего: HF_IMAGE_MODELS и так уже лежит в памяти целиком,
70
- а пересборка списка из 5 элементов не стоит отдельного кэш-слоя с ручной
71
- инвалидацией на каждом сайте вызова. Убрано вместе с самим кэшем."""
72
- return [{"id": mid, **meta} for mid, meta in HF_IMAGE_MODELS.items()]
73
-
74
-
75
- def _imgmodel_keyboard(current: str) -> InlineKeyboardMarkup:
76
- """Клавиатура выбора модели генерации изображений, два столбца.
77
-
78
- ПОНИЖЕНО (ponytail-audit): раньше здесь была постраничная навигация
79
- (HF_IMAGE_MODEL_PAGE_SIZE=8, кнопки "< Назад"/"Дальше >") — при каталоге
80
- из 5 моделей она всегда давала ровно одну страницу и ни разу не могла
81
- сработать: nav-кнопки никогда не появлялись. Убрано вместе с параметром
82
- `page`; если каталог когда-нибудь вырастет за пределы одного экрана
83
- пагинацию стоит вернуть тогда, а не держать её мёртвым кодом сейчас."""
84
- all_models = _hf_model_catalog()
85
- rows: list[list[InlineKeyboardButton]] = []
86
- for i in range(0, len(all_models), 2):
87
- row = [
88
- InlineKeyboardButton(
89
- text=f"{'• ' if item['id'] == current else ''}{item['name']}",
90
- callback_data=f"imgmodel:set:{item['id']}",
91
- )
92
- for item in all_models[i:i + 2]
93
- ]
94
- rows.append(row)
95
- return InlineKeyboardMarkup(inline_keyboard=rows)
 
 
 
 
 
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
- изменения), просто тихо игнорируются при чтении — см. _restore_single_chat в
 
 
 
 
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
- # Раньше был отдельный HF_IMAGE_MODEL_CACHE + async _hf_fetch_model_catalog(),
2375
- # оба вестигиальные (динамический фетч из HF API убран, каталог всегда 1:1 из
2376
- # HF_IMAGE_MODELS) заменены на синхронный _hf_model_catalog(). Тестов на этот
2377
- # слой раньше не было вообще; добавлены вместе с упрощением, чтобы не убрать
2378
- # индирекцию "молча".
2379
-
2380
- def test_hf_model_catalog_matches_hf_image_models_exactly():
2381
- catalog = bot._hf_model_catalog()
2382
- assert [m["id"] for m in catalog] == list(bot.HF_IMAGE_MODELS.keys())
2383
- for m in catalog:
2384
- assert m["name"] == bot.HF_IMAGE_MODELS[m["id"]]["name"]
2385
-
2386
-
2387
- def test_imgmodel_keyboard_marks_current_model_no_pagination():
2388
- # Пагинация убрана (ponytail-audit) — каталог из 5 моделей всегда умещается
2389
- # на одном экране, поэтому клавиатура больше не принимает `page` и никогда
2390
- # не рисует "< Назад"/"Дальше >".
2391
- kb = bot._imgmodel_keyboard(bot.DEFAULT_HF_IMAGE_MODEL)
2392
- all_buttons = [btn for row in kb.inline_keyboard for btn in row]
2393
- current_buttons = [b for b in all_buttons if b.text.startswith("• ")]
2394
- assert len(current_buttons) == 1
2395
- assert bot.HF_IMAGE_MODELS[bot.DEFAULT_HF_IMAGE_MODEL]["name"] in current_buttons[0].text
2396
- assert not any("Назад" in b.text or "Дальше" in b.text for b in all_buttons)
2397
- assert all(b.callback_data.startswith("imgmodel:set:") for b in all_buttons)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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