Как приручить нейросеть и создать новогоднего Telegram-бота, который умеет «предсказывать» будущее

от автора

Мне нравится создавать Telegram-ботов и экспериментировать с нейросетями, поэтому часто объединяю их в своих проектах. Коллеги заметили мое хобби и предложили разработать новогоднего гида Selectel, который будет генерировать персональные предсказания и советы на 2024 год для наших читателей.

Во время реализации спецпроекта я впервые столкнулся с ограничениями по маркетинговой части проекта. Как заставить ChatGPT придумывать разнообразные, но только положительные предсказания? Как не выйти за лимиты текстовой модели и Telegram? В этой статье делюсь ответами на вопросы и реакцией пользователей на проект.

Используйте навигацию, чтобы выбрать интересующий блок:

Постановка задачи
Архитектура приложения
Замена иллюстраций
Массовая отправка сообщений
Настройка текстовой модели
Статистика
Заключение

Постановка задачи

Макет взаимодействия с ботом. Часть 1.

Перед началом разработки мы с коллегами подробно обсудили характеристики бота (новогоднего гида) и определились с функциональностью.

  • Взаимодействие с ботом происходит через одно сообщение. В терминах Telegram — через InlineKeyboardMarkup.
  • С помощью текстовой модели бот генерирует предсказание или совет — выбор остается за пользователем. При этом предсказание может быть на тему путешествий, отношений, здоровья, образования или карьеры. А совет — о том, какой посмотреть фильм или сериал, какую книгу почитать или что приготовить.
  • На вопрос «Кто я в год дракона?» выводит одну из заготовленных картинок.
  • Новогодний гид должен предлагать пользователям участвовать в розыгрыше, а по завершении — разослать всем результаты. Для этого нужно настроить массовую рассылку сообщений.

Все требования строятся вокруг «фронтенда» и логики бота. Это значит, что я могу использовать любые удобные инструменты для решения задачи.

Архитектура приложения

Диаграмма развертывания.

Основной принцип работы прост: бот должен обращаться к текстовой модели и передавать ответ пользователю. Но здесь появляется загвоздка: часто модель не отвечает мгновенно, а у официальной библиотеки OpenAI нет асинхронного интерфейса. Кроме того, нужно предусмотреть повторы (retry) в случае сетевой или программной ошибки и научиться сохранять задания, чтобы они не слетали при перезагрузке бота.

Чтобы решить эти проблемы, я разделил приложение на две части и делегировал задачи брокеру сообщений. В первой (bot) использовал фреймворк aiogram, который обрабатывает обновления из Telegram Bot API и выполняет «быстрые» задачи — например, навигацию в меню. В нашем случае — получает запрос на совет или предсказание от пользователя и перекладывает его в очередь сообщений.

Во второй части приложения находится обработчик (worker). Он извлекает сообщения из очереди, преобразует в запрос и отправляет к текстовой модели. Для работы с брокером сообщений я использовал библиотеку dramatiq.

# Компонент bot @router.callback_query(Callback.filter(F.action == Action.request_answer)) async def handle_prediction_message(query: CallbackQuery, callback_data: Callback, bot: Bot):     # Сообщение-заглушка пользователю      # Отправка сообщения в брокер     request_completion.send(...)   # Компонент worker async def _request_completion(*args, **kwargs):     # Логика обращения к текстовой модели          # Отправка результата в Telegram     await bot.send_photo(...)   @dramatiq.actor def request_completion(*args, **kwargs):     loop.run_until_complete(_request_completion(*args, **kwargs)) 

С архитектурой определились. Далее нужно было сделать меню с красивыми иллюстрациями.

Если вам интересно читать топики о программировании, Telegram, играх и других технологиях, подписывайтесь на мой канал, где периодически пишу на разные темы. А еще — делитесь мнением о пятой версии Midjourney в комментариях.

Замена иллюстраций

Макет взаимодействия с ботом. Часть 2.

Давайте еще раз взглянем на макет. Для каждой «страницы» меню нужно добавить уникальное изображение в виде анимированного стикера. Однако технические ограничения Telegram превращают его в обычное анимированное изображение, поскольку у фото, видео и анимации может быть подпись, а у стикера — нет.

Заглянем в документацию. Telegram Bot API позволяет заменять контент в сообщениях на произвольный, но меню — это фиксированный набор заранее заготовленных изображений. Конечно, мы можем каждый раз добавлять новые, но загрузка однотипных картинок требует много времени, да и такая активность не понравится Telegram. Поэтому, чтобы сэкономить трафик и время на формирование ответа, мы один раз загружаем изображения на серверы Telegram, а далее используем их повторно.

Оптимизация многообещающая, но нужно учесть следующие подводные камни.

  • изображения должны «принадлежать» боту, поскольку именно он их и загружает.
  • кэшированные изображения должны быть в каком-нибудь чате.

Последний пункт заслуживает более подробного объяснения. Если в процессе разработки вы загрузите кэш изображений в вашу переписку с ботом, а потом очистите историю, то первое время все будет работать. Но вскоре Telegram удалит вложения, которые «не привязаны» к чату, и идентификатор станет некорректным.

Один из вариантов решения — создать закрытый чат в качестве хранилища для актуальных изображений бота. Для этого я сделал недокументированную команду, которая обновляет кэш иллюстраций:

@router.message(Command("/cache")) async def command_cache(message: Message, bot: Bot) -> None:     if message.from_user.id not in DEVELOPER_IDS:         return      dir = "images"     for filename in os.listdir(dir):         if os.path.isdir(f"{dir}/{filename}"):             continue          file = FSInputFile(f"{dir}/{filename}")         if filename.endswith(".png"):             message = await message.answer_photo(file)             await pic_update(filename, message.photo[0].file_id)         else:             message = await message.answer_video(file)             await pic_update(filename, message.video.file_id)      await message.answer("ok") 

В результате служебный чат быстро «уедет вниз» и не будет мешать во время тестирования. А это значит, что кэш останется в безопасности.

Массовая отправка сообщений

Bot API нет универсального решения для отправки сообщений всем пользователям бота. Однако в библиотеке есть достаточно комфортные условия для разработки собственных. За секунду Bot API может отправить сообщения в 30 чатов, при этом в каждый из них можно прислать не более 20 сообщений за минуту.

Также у сообщений есть информация, которую нужно где-то хранить, поэтому для этого я использовал две таблицы:

  • new_year_broadcast_message — текст сообщения и идентификатор админа,
  • new_year_broadcast_message_entry — запись об отправке указанного сообщения.

По умолчанию запись об отправке создается со значением sent = False. Это означает, что сообщение еще не было отправлено.

Поскольку Python довольно медленный, перебор пользователей через SQLAlchemy занимает много времени. Поэтому вместо него я использовал SQL:

engine = create_async_engine(...) async_session: async_sessionmaker[AsyncSession] = async_sessionmaker(engine, expire_on_commit=False)  async with async_session() as session:     session: AsyncSession        # Создаем сообщение, коммит назначает ему идентификатор     broadcast = BroadcastMessage(         from_user_id=user_id,         text=text     )     session.add(broadcast)     await session.commit()      # Делаем подзапрос, у которого в первой колонке идентификаторы всех     # известных пользователей, а во втором — константа     select_stmt = select(User.id, broadcast.id)      # Одним запросом создаем по строке на каждого известного пользователя     stmt = insert(BroadcastMessageEntry).from_select(["user_id", "broadcast_id"], select_stmt)     await session.execute(stmt)     await session.commit() 

Далее я реализовал функциональность для самой рассылки:

async def do():     async with async_session() as session:         session: AsyncSession          # Лимит в этом запросе ограничивает количество сбщ в секунду         stmt = select(BroadcastMessageEntry).options(selectinload(BroadcastMessageEntry.message)).where(BroadcastMessageEntry.sent == False).limit(5)         result = await session.execute(stmt)          for e in result.fetchall():             entry: BroadcastMessageEntry = e[0]             try:                 await bot.send_message(                     chat_id=entry.user_id,                     text=entry.message.text,                 )                 entry.sent = True                 logger.info(f"Sending {entry.broadcast_id} to user {entry.user_id} successful")             except TelegramForbiddenError:                 # Пользователь нас заблокировал                 entry.sent = True                 logger.info(f"Sending {entry.broadcast_id} to user {entry.user_id} failed with blocked chat. Marked as successful")             except TelegramBadRequest as e:                 # Тоже возможный вариант блокировки                 entry.sent = True                 logger.info(f"Sending {entry.broadcast_id} to user {entry.user_id} failed with BadRequest: {e}. Marked as successful")             except Exception as e:                 # Ограничение по спаму, недоступность сервера, etc...                 logger.exception(e)                 break          await session.commit()  async def scheduler():     # Например, через aioschedule планируем отправку     aioschedule.every(1).seconds.do(do)      while True:         await aioschedule.run_pending()         await asyncio.sleep(1)  asyncio.create_task(scheduler()) 

Для отправки массовых сообщений я использовал aioschedule, запущенный в том же цикле событий (event loop), что и aiogram. Ограничение в пять чатов в секунду, конечно, предусмотрительное, но зато хорошо работает.


Настройка текстовой модели


Хитрые пользователи могут «развести» ChatGPT на незапланированные откровения, поэтому от пользовательского ввода мы сразу отказались и оставили только категории. А чтобы снизить стоимость запроса к текстовой модели, решили запрашивать совет или предсказание без истории:

messages = [     {         "role": "system",         "content": "Ты - рекомендательная система. Твой ответ - короткая аннотация."     },     {"role": "user", "content": "Посоветуй книгу"}, ] response = openai.ChatCompletion.create(     model="gpt-3.5-turbo",     temperature=0.6,     messages=messages ) 

Ниже — результаты, которые мы получили. Кажется, они выглядят адекватно.

  • Фильм — «Побег из шоушенка».
  • Сериал — «Игра престолов».
  • Книга — «1984» Джорджа Оруэлла.

Далее нужно было разнообразить вывод. Для этого можно повысить параметр «температура», но тогда текстовая модель начнет бредить и выдавать несуществующие произведения. Другой способ — случайно выбрать жанр, добавить модификатор для совета и имя пользователя в Telegram:

_advice_modifier = [     "жизнерадостной",     "интригующей",     "забавной",     … ]  _advice_book_genre = [     "жанра фантастика",     "о морских путешествиях",     "про поиск работы",     ... ]   modifier = random.choice(_advice_modifier) messages = [     {         "role": "system",         "content": "Ты - рекомендательная система. Твой ответ - короткая аннотация."     },     {         "role": "system",         "content": f"Твоя рекомендация должна быть {modifier}!",     },     {         "role": "system",         "content": f"Твоего собеседника зовут {full_name}. "     },     {         "role": "user",          "content": f"Посоветуй книгу {random.choice(_advice_book_genre)}"     }, ] 

Во время тестирования на коллегах мы выяснили, что использование отображаемого имени в советах — плохая идея.

Василиса Примерова: *выбирает категорию «книги»*.
Бот: Я рекомендую тебе книгу «Королева тьмы» от Василисы Примеровой. Это захватывающее историческое фэнтези, где встречаются сильные женские персонажи, магия и эпические сражения. Погрузись в мир, где прошлое переплетается с фантазией и приключениями!

Список жанров и положительных модификаторов дает достаточное разнообразие в советах без угрозы попасть на непрошенный ужастик. Хотя даже с учетом подбора жанров и отладки на коллегах в «прод» попали следующие советы:

«Хватит мяукать, начни рычать: как стать настоящим львом» — удивительная книга, которая расскажет тебе, как обрести силу и уверенность, примерив на себя манеры настоящего царя джунглей. Забудь о мяуканье и покажи всем, что ты настоящий зверь!

Как насчет «Волшебник-паук и драконья принцесса: Приключения волшебного мира, где драконы играют в бейсбол»? Эта книга полна волшебства, приключений и, конечно же, невероятных игр драконов в любимую американскую игру! Отправляйтесь вместе с главными героями в увлекательное путешествие по фантастическому миру, где мечты сбываются, а драконы — настоящие звезды бейсбола!

Самая потешная категория даже после всех настроек — блюда для готовки. Вот примеры:

Как насчет волшебных тестовых крыльев? Они легкие, хрустящие и просто исчезают во рту!

Как насчет «Курицы-ниндзя»? Это блюдо из куриного филе, обжаренного в соусе терияки с добавлением свежих овощей и немного остроты. Готовься к взрыву вкуса и ниндзя-приключениям на твоем языке!

Попробуй веганскую «Шаурму без шаурмы» — это сочные овощи, обжаренные вкусняшки и ароматные соусы, завернутые в питу. Она настолько вкусная, что даже морковка начнет мечтать о том, чтобы стать шаурмой!

Ах, азиатская кухня — это настоящий фейерверк вкусов! Моя рекомендация — попробуй суши «Драконий огонь»! Это сочные роллы с лососем, авокадо и огненным соусом. Они такие острые, что твой рот будет взрываться вкусом, словно дракон пускает огонь! Ощути яркость вкуса и погружение в азиатскую культуру с этими потрясающими суши!

Острое блюдо? О, я знаю идеальную рекомендацию для тебя — «Вулкан вкуса»! Это блюдо состоит из сочной мясной начинки, огненно-острого соуса и тающего сыра, все это завернуто в лепешку и запечено до золотистой корочки. Гарантирую, что твой вкусовой вулкан точно взорвется от удовольствия! Будь готов к пожару на твоем языке!

Техника, обкатанная на советах, была незначительно модифицирована для предсказаний. Мы добавили персонажа, от имени которого модель должна писать текст:

messages = [     {         "role": "system",         "content": f"Ты пишешь краткие и лаконичные предсказания на 2024 год. Твой ответ - один абзац.",     },     {         "role": "system",         "content": (             f"Пиши предсказание, как будто ты {person}."         )     },     {         "role": "system",         "content":             f"Твоего собеседника зовут {full_name}. "             f"Напиши персонализированное предсказание для этого человека.",     },     {         "role": "system",         "content": f"Предсказание должно быть {prediction_modifier}",     },     {"role": "user", "content": f"Тема: {text}"}, ] 

Ограничение по длине сообщения — один абзац. Потому что попытки ограничить бота по количеству символов сильно блокировали фантазию ChatGPT. А вариант через количество предложений часто приводил к нумерованным спискам:

1. В 2024 году ты найдешь истинную любовь, которая будет наполнять твою жизнь счастьем и радостью.

2. Твои отношения будут строиться на взаимопонимании и уважении, что приведет к гармонии и стабильности в твоей личной жизни.

3. Будь открытой и честной с партнером, чтобы создать крепкую и продолжительную связь.

4. Важно помнить, что истинная любовь требует усилий и компромиссов, но она стоит каждой затраченной минуты.

5. Не забывай о самолюбии и самоуважении — любовь к себе является фундаментом для здоровых и счастливых отношений.

Далее остается только опытным путем подбирать все модификаторы.

Статистика

За 40 дней работы бота пришло 4 016 пользователей, которые совершили 14 419 запросов. Почти половина запросов — это «Кто я в год дракона?», то есть заранее заготовленные тексты и уникальные иллюстрации. Среди предсказаний пользователей интересует вопрос отношений и карьеры, а среди советов — фильмы и книги.

В среднем пользователи совершали по четыре запроса, но медианное значение — два. Рекорд — 91 запрос к боту от одного пользователя.

На графике выше видно, что какая-то из маркетинговых активностей привела к «всплеску». 14 декабря было более 5 000 запросов к текстовой модели, а пиковая частота составила пять обращений в секунду. Как и ожидалось, с наступлением нового года активность снизилась и пользователи отключили бота.

Уведомление от BotFather.

В этот день новогодний гид залип на пару минут, что привело к срабатыванию мониторинга для крупных ботов в Telegram и уведомлению от BotFather. Недоступность, конечно, отрицательное явление, но активация мониторинга — это признание для нашего бота.

На графике выше отмечено количество обращений к текстовой модели, но каждое из них — это два-три клика по кнопкам в интерфейсе Telegram, так что в действительности бот обрабатывал больше 300 действий в минуту.

Заключение


Я доволен, что согласился поучаствовать в этом спецпроекте. Это отличная возможность проверить свое умение писать Telegram-ботов на большом количестве пользователей.

Расскажите в комментариях, удалось ли вам ознакомиться с нашим новогодним ботом? Будет интересно узнать ваше мнение.


ссылка на оригинал статьи https://habr.com/ru/articles/786988/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *