В этой статье хочу поделиться интересным опытом работы с ChatGPT, в рамках которого
был написан телеграм-бот на языке Python, использующий API OpenAI для
получения ответов на запросы.

Началось все с того, что я захотел зарегистрировать себе аккаунт на OpenAI и понять, кто такой этот ваш ChatGPT. Немного танцев с бубном, и желаемая цель была достигнута. Бот понравился, но возникали некоторые неудобства из-за доступа по VPN. Тогда у меня появилась идея создать телеграм-бота, который будет отправлять запросы в API OpenAI и возвращать ответы.

Я потратил следующий час на чтение документации по API OpenAI, но моя лень оказалась более находчивой, и я решил дать ChatGPT задание написать бота самостоятельно.

Что из этого вышло:

Начало киберпанка

Текст ТЗ для ChatGPT

Приветствую, мой дорогой! У меня для тебя задание: написать
телеграм-бота на Python, который будет отправлять запросы в API OpenAI и
возвращать ответы. Для работы с API телеграм давай использовать
библиотеку telebot

  1. Телеграм-бот должен иметь два обработчика команд - /start и /help. Текст ответа на команды может быть любым.

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

  3. Когда бот получает запрос от пользователя, необходимо вызвать метод create у класса Completion с использованием библиотеки OpenAI. В качестве параметров задаем модель text-davinci-003 и max_tokens = 3500.

  4. Затем мы получаем ответ от API OpenAI и возвращаем его пользователю.

ChatGPT получил от нас задание и принялся за работу. Стоит отметить, что предоставленный код, будет использован без изменений и рефакторинга.

Вот сгенерированный им код:

import telebot
import openai
import os

# устанавливаем ключ API OpenAI из переменной окружения
openai.api_key = os.environ.get("OPENAI_API_KEY")

# создаем экземпляр телеграм бота
bot = telebot.TeleBot('YOUR_TOKEN')


# создаем обработчик команд
@bot.message_handler(commands=['start'])
def start(message):
    bot.reply_to(message, "Привет! Я бот, который может помочь вам с OpenAI API.")


@bot.message_handler(commands=['help'])
def help(message):
    bot.reply_to(message,
                 "Вы можете отправлять запросы в OpenAI API через меня. Просто напишите мне свой запрос и я отправлю его на обработку.")


# создаем обработчик сообщений
@bot.message_handler(func=lambda message: True)
def echo(message):
    bot.reply_to(message, "Запрос принят в работу.")
    try:
        response = openai.Completion.create(
            engine="text-davinci-003",
            prompt=message.text,
            max_tokens=3500
        )
        bot.reply_to(message, response.choices[0].text)
    except:
        bot.reply_to(message, "Произошла ошибка при обработке вашего запроса.")


# запускаем телеграм бота
bot.polling()

Немного поправляем этот участок кода bot = telebot.TeleBot('YOUR_TOKEN'), чтобы значение тянулось из переменных окружения.

Первое сообщение боту

Вуаля! Буквально одна правка и все работает. Но мы хотим продолжить беседу про дождь и уточнить еще некоторых моментов, и получаем что-то странное.

Продолжаем беседу про дождь, но не упомянем о нем явно

Проблема здесь заключается в том, что бот не способен учитывать контекст разговора, поскольку каждое новое сообщение отправляется в API отдельно. Просмотрев документацию, я понял, что здесь нет явного решения этой проблемы. (Если я не прав, поправьте меня, пожалуйста).

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

С учетом этой информации, мы готовы внести некоторые изменения в исходное ТЗ.

Текст ТЗ №2 для бота

Задача требует внести изменения в код бота, чтобы он мог сохранять
контекст предыдущих сообщений пользователей и использовать его при
отправке запросов в API OpenAI.

  1. Для хранения контекста необходимо использовать как массив, так и баз данных SQLite. Бот должен сначала пытаться получить информацию о контексте из массива. При этом массив должен быть очищен через 10 минут. Если контекст не найден в массиве или время его хранения истекло, бот должен обратиться к базе данных.

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

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

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

Получаем от бота вот такой результат:

import telebot
import openai
import os
import sqlite3
from datetime import datetime, timedelta

# устанавливаем ключ API OpenAI из переменной окружения
openai.api_key = os.environ.get("OPENAI_API_KEY")

# создаем экземпляр телеграм бота
bot = telebot.TeleBot(os.environ.get("TG_API_KEY"))

# создаем подключение к базе данных
conn = sqlite3.connect("example.db", check_same_thread=False)

# создаем таблицу в базе данных для хранения контекста
with conn:
    cur = conn.cursor()
    cur.execute("CREATE TABLE IF NOT EXISTS context (user_id TEXT, message TEXT, timestamp TEXT)")

# задаем интервал, через который массив с контекстом будет очищаться
CONTEXT_CACHE_INTERVAL = timedelta(minutes=10)

# словарь, в котором будут храниться последние запросы пользователя
context_cache = {}


# создаем обработчик команд
@bot.message_handler(commands=['start'])
def start(message):
    bot.reply_to(message, "Привет! Я бот, который может помочь вам с OpenAI API.")


@bot.message_handler(commands=['help'])
def help(message):
    bot.reply_to(message,
                 "Вы можете отправлять запросы в OpenAI API через меня. Просто напишите мне свой запрос и я отправлю его на обработку.")


# создаем обработчик сообщений
@bot.message_handler(func=lambda message: True)
def echo(message):
    # смотрим, есть ли контекст в кэше
    if message.chat.id in context_cache and datetime.now() - context_cache[message.chat.id]['timestamp'] <= CONTEXT_CACHE_INTERVAL:
        context = context_cache[message.chat.id]['message']
    else:
        # если контекста в кэше нет, ищем его в базе данных
        with conn:
            cur = conn.cursor()
            cur.execute("SELECT message FROM context WHERE user_id = ? ORDER BY timestamp DESC LIMIT 1", (str(message.chat.id),))
            row = cur.fetchone()
            context = row[0] if row else ""

    bot.reply_to(message, "Запрос принят в работу.")
    try:
        response = openai.Completion.create(
            engine="text-davinci-003",
            prompt=context + message.text,
            max_tokens=3500
        )
        bot.reply_to(message, response.choices[0].text)

        # сохраняем контекст в кэше и базе данных
        with conn:
            cur = conn.cursor()
            cur.execute("INSERT INTO context (user_id, message, timestamp) VALUES (?, ?, ?)", (str(message.chat.id), context + message.text, datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
            conn.commit()
        context_cache[message.chat.id] = {'message': context + message.text, 'timestamp': datetime.now()}

    except Exception as e:
        bot.reply_to(message, f"Произошла ошибка при обработке вашего запроса: {str(e)}")


# запускаем телеграм бота
bot.polling()

Программа успешно запущена, и мы проверяем наличие базы данных с тремя столбцами. Мы продолжаем общаться с ботом на тему дождя и видим, что он начал выглядеть как разговор с искусственным интеллектом, а не просто как с генератором рандомных текстов.

Вопрос про дождь с контекстом

К сожалению, такое решение может иметь побочный эффект. Если вы ведете продолжительную беседу на разные темы, то есть риск, что бот может ответить на все ваши предыдущие вопросы, даже если последнее сообщение не имело к ним никакого отношения. Хотя этот эффект не всегда наблюдается.

Побочный эффект от костылей

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

response = openai.Completion.create(
            engine="text-davinci-003",
            prompt=context + message.text,
            max_tokens=3500
        )

Мы продолжаем вести непринужденную беседу с ботом, как вдруг получаем ошибку: Произошла ошибка при обработке вашего запроса: This model's maximum context length is 4097 tokens, however you requested 4654 tokens (1154 in your prompt; 3500 for the completion). Please reduce your prompt; or completion length.

Это происходит из-за ограничения на количество символов в запросе, которое установлено в API OpenAI. А мы еще тут контекст копим в кэше. Поэтому все также можно попросить бота исправить ошибку, и он дает нам такой код:

@bot.message_handler(commands=['drop_cache'])
@restricted_access
def drop_cache(message):
    user_id = message.from_user.id

    conn = get_conn()
    cursor = conn.cursor()

    cursor.execute('DELETE FROM context WHERE user_id=?', (user_id,))

    hot_cache.clear()

    conn.commit()
    bot.send_message(user_id, "Cache dropped.")

Здесь мы очищаем кеш для конкретного user_id, чтобы уложиться в ограничения api.

В итоге мы получаем рабочее решение. Его можно дальше улучшать и настраивать. Исходный код бота доступен здесь.