Изображение сгенерировано с помощью AI, ни один человек в процессе не пострадал
Изображение сгенерировано с помощью AI, ни один человек в процессе не пострадал

Пару месяцев назад мы решили принять участие в недавней конференции Highload, и нам потребовалось что-то интересное и интерактивное, чтобы привлечь людей к нашему стенду. После некоторых раздумий выбор пал на создание чат-бота. Но совсем не типичного: основная его цель - общение с пользователями в игровом формате. Бот показывает изображение, сгенерированное AI, и предлагает составить промт, который бы максимально точно воссоздал это изображение. Довольно занимательно, правда?

Сроки поджимали, до конференции оставалось всего два дня, и, поскольку все остальные были заняты, я вызвался разработать  Telegram бота. Честно признаться, такой опыт был у меня впервые, но я верил в силу всемогущего искусственного интеллекта, и принялся за дело!  

Для тех, кто не знаком, GPT Engineer - это инструмент, схожий с Auto-GPT. Он способен автономно генерировать код и создавать целые приложения, основываясь лишь на описании. По крайней мере, такова теория. На практике все немного сложнее: да, он может генерировать код, но успешно запустить его - уже совсем другая история. Это хорошая отправная точка, однако для достижения желаемого результата вам, скорее всего, придется дорабатывать и корректировать ее. Об этом, и о том как скоро AI заменит программистов - в конце статьи.

Чтобы использовать GPT Engineer, нужно склонировать репозиторий и настроить его, следуя инструкциям в прилагаемом файле README. По сути, это набор скриптов на Python, поэтому процесс настройки относительно прост. Когда все будет готово, опишите, что вы хотите сделать в файле 'main_prompt', который вам нужно будет создать в папке projects. Затем запустите инструмент и подождите, пока он сгенерирует код.

Когда дело доходит до описания того что вы хотите получить, точность имеет ключевое значение. Нужно как можно подробнее описать желаемый результат и стек, которые вы хотите использовать. Кроме того, желательно использовать последнюю версию API OpenAI (я использовал версию 4), так как результаты значительно отличаются в зависимости от версии. Вот мой гениальный промт:

Bot design prompt

Overview

This document outlines the design for a game where players guess the textual prompts used to generate images by DALLE-3. The game will be implemented using the OpenAI API, Python programming language, and Telegram API. It is a guessing game where players are shown an image generated by DALLE-3 and must guess the prompt used to create it.

Game Flow
Start: The player starts the game through a Telegram bot.
Image Presentation: A DALLE-3 generated image is presented to the player.
Guessing Phase: The player has 3 attempts to guess the prompt.
Submission: After 3 attempts, the player submits their final guess.
Data Storage: The game stores the player’s guess and other relevant data.

Technical Components

OpenAI API

Purpose: To generate images based on textual prompts.
Integration: Python scripts will interact with the OpenAI API to request image generation.

Python Backend

Functionality:
Communicate with the OpenAI API.
Handle Telegram bot interactions.
Manage game logic and state.
Store and retrieve data.

Libraries:
python-telegram-bot: For Telegram bot interactions.
requests: For making API calls to OpenAI.
os, json: For file handling and configuration management.

Telegram API
Purpose: To provide a platform for users to play the game.
Integration:
The game will be accessible through a Telegram bot.
The bot will handle user inputs and display images and messages.

Game Logic
Starting Image:
Read an image name from the config file, load it from the root folder.
User Interaction:
Present the image to the user via Telegram bot.
Receive guesses from the user.
After each guess - use OpenAI API to generate an image from that prompt and
show this image to the user.
Allow up to 3 attempts per image.
Data Handling:
After 3 attempts or final submission, save the user’s guess.
Store the image, prompt, user’s guess, Telegram username, and account info.

Data Storage
Location: user_data folder in the bot’s root directory.
Structure:
Subfolders named after the user’s Telegram username.
Filder contains info.txt file with all user data and an image generated by the user.

Configuration File
File Name: bot_config.json
Contents:
API keys.
Game settings (e.g., time limits, number of attempts).
Other configurable parameters.

Internationalization
gettext should be used for all text messages

Как видите, довольно подробно. На основе этого тула сгенерировала проект, который я попытался запустить - безуспешно. Оказалось, что данные, на которых обучался OpenAI, были устаревшими. Telegram API значительно изменился несколько месяцев назад, и модель не знала об этом. Поэтому, покопавшись в документации Telegram (параллельно про себя проклиная Дурова и всю его команду за то, что они так часто меняет API), я смог настроить код.

Но прежде чем его запустить, нам нужно зарегистрировать бота в Telegram. Это можно сделать, обратившись к специальному боту Telegram под названием BotFather.

BotFather - это крестный отец официальный бот Telegram, который позволяет создавать и управлять ботами. Чтобы начать, найдите '@BotFather' в строке поиска Telegram и начните с ним чат. Далее:

1. Создайте нового бота: отправьте BotFather команду /newbot. После этого BotFather предложит вам выбрать имя и юзернейм для вашего бота. Имя - это то, что пользователи будут видеть в беседах, а юзернейм - то, как ваш бот будет найден в Telegram. Юзернейм должен заканчиваться на "bot", например "examplebot".

2. Получите токен: После того как вы укажите боту имя, BotFather даст его токен. Это уникальный идентификатор для вашего бота, который используется для аутентификации ваших запросов к Telegram Bot API.

3. Настройте бота (необязательный пункт): С помощью BotFather вы можете настроить картинку профиля бота, его описание, информацию о нем и многое другое. Эти команды необязательны, но помогут сделать ваш бот более привлекательным.

Теперь, когда вы зарегистрировали своего бота, давайте посмотрим на код.

Здесь находится класс main.py - загрузчик игры, с помощью которого мы будем запускать бота:

from bot_config import BotConfig
from telegram.ext import Application

def main():           
   # Load our bot configuration
   config = BotConfig.load_config()
   application = Application.builder().token(token=config.telegram_token).build()

   # Instantiate the bot
   game = Game(config)
   game.start(application)
   print("Bot has started")

Application - это класс Telegram API, который управляет приложением Telegram.

Основная логика игры будет находиться в классе Game:

def start(self, application: Application):
   self.app = application

   # Add command handlers
   application.add_handler(CommandHandler("start", self.handle_start))
   application.add_handler(CommandHandler("rules", self.handle_print_rules))
   application.add_handler(MessageHandler(Filters.TEXT & ~Filters.COMMAND, self.handle_message))

   # Start the bot
   application.run_polling(allowed_updates=Update.ALL_TYPES)

Здесь мы определяем типы команд/сообщений, на которые будет реагировать наш бот. У нас будет 3 типа: команда start - для запуска бота, команда rules, которая показывает правила игры пользователю, и любой рандомный message, обычно являющийся описанием изображения, созданного пользователем.

Поскольку одновременно может играть множество пользователей, нам необходимо отслеживать каждую сессию. Для этого в конструкторе игры мы создадим словарь под названием sessions.

class Game:
   def __init__(self, config):
       self.sessions = {}
       self.config = config
       self.app = None

Когда мы получаем сообщение от пользователя, мы решаем, является ли он вернувшимся пользователем - в этом случае мы загружаем его сессию из папки user_data (из файла info.txt), или новым пользователем, в этом случае мы создаем новую сессию:

def get_user_session(self, update: Update):
   user_id = update.message.from_user.id

   if user_id not in self.sessions:
       user = User(user_id, update.message.from_user.full_name, update.message.from_user.username)
       session = self.load_user_session(user)
       self.sessions[user_id] = session
   return self.sessions[user_id]

  
def load_user_session(self, user):
   user_folder = f'user_data/{user.telegram_name}'
   if not os.path.exists(user_folder):
       os.makedirs(user_folder)

   info_path = os.path.join(user_folder, 'info.txt')

   if not os.path.exists(info_path):
       user.image_submitted = ''
       user.attempts_left = self.config.max_attempts
       return GameSession(user, self.config)

   with open(info_path, 'r') as info_file:
       info_file.readline()
       info_file.readline()
       info_file.readline()
       user.image_submitted = info_file.readline().split(':')[1].strip()
       user.attempts_left = int(info_file.readline().split(':')[1].strip())
       user.last_prompt = info_file.readline().split(':')[1].strip()
       user.position = info_file.readline().split(':')[1].strip()
       user.company = info_file.readline().split(':')[1].strip()

   session = GameSession(user, self.config)
   return session

И наконец, мы хотим определить хендлеры для всех команд:

async def handle_start(self, update: Update, context: CallbackContext):
   if update.message.from_user is None:
       return

   session = self.get_user_session(update)
   await session.start(update, context)
    
async def handle_message(self, update: Update, context: CallbackContext):
   session = self.get_user_session(update)
   await session.handle_message(update, context)
  
async def handle_print_rules(self, update: Update, context: CallbackContext):
   session = self.get_user_session(update)
   await session.handle_print_rules(update, context)

Как вы можете видеть, мы делегируем обработку всех команд классу пользовательской сессии, потому что сессия будет иметь необходимый контекст для этого. Здесь находится класс GameSession, в котором и происходит вся реальная работа:

class GameSession:
   STATE_NOT_STARTED = 'not_started'
   STATE_WAITING_FOR_POSITION = 'waiting_for_position'
   STATE_WAITING_FOR_COMPANY = 'waiting_for_company'
   STATE_WAITING_FOR_PROMPT = 'waiting_for_prompt'
   STATE_WAITING_FOR_CONFIRMATION = 'waiting_for_confirmation'
    
   def __init__(self, user, config, is_running = False):
       self.config = config
       self.user = user
       self.dalle_service = DalleService(config.openai_api_key)
       self.last_image = None
       self.attempts_left = user.attempts_left
       self.state = self.STATE_NOT_STARTED if not is_running else self.STATE_WAITING_FOR_PROMPT

Как видите, у игры могут быть разные статусы, которыми нам нужно управлять. Это может быть ожидание промта пользователя для создания изображения или ожидание подтверждения для определенных действий. Она также отслеживает оставшиеся попытки и текущее состояние дел. В самом начале игра отправляет пользователю правила игры и запрашивает начальное изображение:

async def start(self, update: Update, context: CallbackContext):
   if self.state != self.STATE_NOT_STARTED:
       if (self.attempts_left > 0):
           await update.message.reply_text(_("Game is already running. Please enter a prompt for the image:"))
       else:
           await update.message.reply_text(_("You have no attempts left. Please come back tomorrow."))
       return

   self.state = self.STATE_WAITING_FOR_PROMPT
   await update.message.reply_text(_("Welcome to the 'Culture Code' game! Here are the rules..."))
   await update.message.reply_text(_("Game Rules"))
    
   # Send the predefined image
   with self.get_predefined_image() as image:
       await update.message.reply_photo(photo=image)

   await update.message.reply_text(_("Please enter a prompt for the image:"))

Как только пользователь ответит, будет вызван message handler. Он использует запрос пользователя для генерации изображения с помощью OpenAI Dall-e API, чтобы сгенерировать изображение и показать его пользователю. После этого игра спросит, хочет ли пользователь отправить изображение судьям или хочет попробовать еще раз (если у пользователя остались попытки):

async def handle_message(self, update: Update, context: CallbackContext):
 if self.state == self.STATE_WAITING_FOR_PROMPT:
     # Check if the user has already submitted an image today and have attempts
     if self.user.image_submitted:
         await update.message.reply_text(_("You have already submitted a result today. Please come back tomorrow."))
         return 

     if not self.has_attempts_left():
         await update.message.reply_text(_("You have no attempts left. Please come back tomorrow."))
         return 

     prompt = update.message.text
    
     # Generate image from DALLE-3
     try:
         await update.message.reply_text(_("Generating image..."))
         self.last_image = self.dalle_service.generate_image(prompt)
         self.user.last_prompt = prompt
         await update.message.reply_photo(photo=self.last_image.image_data)

         self.state = self.STATE_WAITING_FOR_CONFIRMATION
         if (self.attempts_left == 1):
             await update.message.reply_text(_("This is your last attempt. Do you want to submit this image for validation? (yes/no)"))
         elif (self.attempts_left == 2):
             await update.message.reply_text(_("You have 2 attempts left. Do you want to submit this image for validation? (yes/no)"))
         else:
             await update.message.reply_text(_("Do you want to submit this image for validation? (yes/no)"))
     except Exception as e:
         await update.message.reply_text(str(e))
 elif self.state == self.STATE_WAITING_FOR_CONFIRMATION:
     if update.message.text.lower() == 'yes':
         if self.submit_attempt(self.last_image):
             await update.message.reply_text(_("Congratulations, the image was submitted!"))
             if not self.user.position:
                 await update.message.reply_text(_("Btw, what is your position in your company?"))
                 self.state = self.STATE_WAITING_FOR_POSITION
                 return
         else:
             await update.message.reply_text(_("Something went wrong! Please enter another prompt:"))
         self.state = self.STATE_WAITING_FOR_PROMPT
     elif update.message.text.lower() == 'no':
         self.attempts_left -= 1
         self.save_user_info()
         if self.has_attempts_left():
             await update.message.reply_text(_("Allright. Attempts left: ") + str(self.attempts_left) + ". " + _("Please enter another prompt:"))
         else:
             await update.message.reply_text(_("You have no attempts left. Please come back tomorrow."))
         self.state = self.STATE_WAITING_FOR_PROMPT
     else:
         await update.message.reply_text(_("Please answer with 'yes' or 'no'"))
 elif self.state == self.STATE_WAITING_FOR_POSITION:
     self.user.position = update.message.text[:100]
     self.save_user_info()
     await update.message.reply_text(_("How interesting! And what is the name of your company?"))
     self.state = self.STATE_WAITING_FOR_COMPANY
 elif self.state == self.STATE_WAITING_FOR_COMPANY:
     self.user.company = update.message.text[:100]
     self.save_user_info()
     await update.message.reply_text(_("Understood. We will validate your image and send you the results soon. Thank you for participating! Please come back tomorrow \xF0\x9F\x98\x89."))
     self.state = self.STATE_WAITING_FOR_PROMPT

Если пользователь решает отправить изображение - игра заканчивается, а изображение сохраняется для последующей оценки жюри:

def submit_attempt(self, image):
   if self.attempts_left > 0 and not image is None and not self.user.image_submitted:
       self.attempts_left = 0
       self.save_user_image(image)
       self.user.image_submitted = image.image_name
       self.save_user_info()
       return True
   return False

def save_user_image(self, image):
   user_folder = f'user_data/{self.user.telegram_name}'
   if not os.path.exists(user_folder):
       os.makedirs(user_folder)

   image_path = os.path.join(user_folder, f'{len(os.listdir(user_folder)) + 1}.jpg')
   with open(image_path, 'wb') as image_file:
       image_file.write(image.image_data)

   return image_path

def save_user_info(self):
   user_folder = f'user_data/{self.user.telegram_name}'
   if not os.path.exists(user_folder):
       os.makedirs(user_folder)

   info_path = os.path.join(user_folder, 'info.txt')
   with open(info_path, 'w') as info_file:
       info_file.write(f"Name: {self.user.user_name}\n")
       info_file.write(f"Id: {self.user.user_id}\n")
       info_file.write(f"Telegram account: {self.user.telegram_name}\n")
       info_file.write(f"Image submitted: {self.user.image_submitted}\n")
       info_file.write(f"Attempts left: {self.attempts_left}\n")    
       info_file.write(f"Last prompt: {self.user.last_prompt}\n")
       info_file.write(f"Position: {self.user.position}\n")  
       info_file.write(f"Company: {self.user.company}\n")       

Вот, собственно, и весь код игры! Есть еще несколько типовых классов для обработки вызовов API и хранения данных о пользователе и изображениях.

Теперь о качестве кода и моих впечатлениях:

Пока что программисты могут вздохнуть спокойно - полноценный проект AI создать не способен, и этому есть несколько причин:

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

  2. Модель галлюцинирует - додумывает методы и свойства которых нет.

  3. Если в рамках одного модуля уже получается довольно связанный код - в рамках всего проекта бывает по разному: генерируются нигде не используемые классы, классы-пустышки, ненужные методы и тп.

  4. С ростом сложности проекта - все вышеперечиселнные проблеммы нарастают.

  5. Качество кода не идеальное с точки зрения стилистики и styleguides: не всегда указывает типы переменных, может не обрабатывать эксепшены и тп.

  6. Самый попобольный момент: модель врет

Доктор Хаус был прав…а теперь и не только human 
Доктор Хаус был прав…а теперь и не только human 

Модель натурально обманула меня один раз, сказав то, что я хотел услышать, а не то, что было правдой! Тут я вспомнил одного разработчика в мою бытность лидом... В общем, совсем как человек. Скоро начнёт просить грейд повыше и смузи...

В общем, будьте осторожны. Этот момент касается не конкретно GPT Engineer, а Chat GPT, который я использовал для доделок.

Тем не менее, я считаю, что с поставленной задачей AI справился хорошо. Для простого бота сложный код с замудрённой архитектурой не только не нужен, но и только мешал бы. Простое, в лоб решение было сгенерировано относительно неплохо, и даже стилистически в целом модель генерирует логичный и понятный код (что даже не нужны комментарии). Такое может далеко лишь не каждый джун, мало кто из них способен (с). Учитывая, что это в принципе было невозможно всего год назад - тут есть над чем задуматься.

Код можно найти здесь: https://github.com/vsemogutor/culture_code_pulbic

Have fun!

Комментарии (4)


  1. AlekseyPechenin
    12.12.2023 18:12

    интересно)


  1. Sanchos_D
    12.12.2023 18:12

    Были ли какие-то баги потом при использовании? Или бот пишет лучше джунов?)


    1. Squirrelfm Автор
      12.12.2023 18:12

      Багов, как ни странно, было не много. Первый до сих пор есть в коде: игра не учитывает что некоторые пользователи в Телеграма делают свой профиль приватным - и бот не может вытащить данные. Из за этого такие пользователи обрабатываются не корректно, т.к. данные пользователя хранятся по его имени, которое не может быть получено. Был так же эпик фейл в первый день, когда были потерянны вс данные - но это уже была ошибка деплоя. Бот хранит последнее сгенерированное изображение для всех пользователей в памяти - и в теории если памяти мало - может сьесть всю памятьна машине (что и произошло, т.к. он был задеплоен на VM с очень маленьким размером RAM)


  1. deFox7
    12.12.2023 18:12

    Полезно, спасибо