О ChatGPT сейчас не говорит только ленивый. Но ему чего-то не хватает, например голоса. Давайте попробуем соединить голосовой помощник Алиса и ChatGPT. Таким образом мы сможем взаимодействовать с ChatGPT с помощью голоса. А он с помощью голоса может нам отвечать. Конечно тут будут ограничения о которых я подробно опишу далее. Данная статья не столько о ChatGPT а о том как писать навыки для Алисы. Было интересно разобраться и написать такой за вечер.
ChatGPT API
Официальное API ChatGPT открыто и для его использования нужен только API_KEY c сайта OpenAI https://platform.openai.com/account/api-keys. Апи платное, есть триал и лимит бесплатного использования на первые три месяца.
Для взаимодействия будем пользоваться официальной питоновской библиотекой openai.
Код взаимодействия с chatGPT моделью:
import os
import openai
OPENAI_API_KEY = os.environ['OPENAI_API_KEY']
async def aquery(message, prev_messages=None):
messages = []
if not prev_messages:
all_messages = []
else:
all_messages = prev_messages.copy()
all_messages.append(message)
for m in all_messages:
messages.append({"role": "user", "content": m})
chat = await openai.ChatCompletion.acreate(model="gpt-3.5-turbo", messages = messages)
reply = chat.choices[0].message.content
reply = reply.strip()
return reply
aquery принимает текст запроса к chatGPT, а также опционально список предыдущих запросов prev_messages. Так мы сможет держать chatGPT в контексте наших предыдущих сообщений и можем поддерживать диалог.
openai.ChatCompletion.acreate функция запроса к chatGPT.
model это модель которую надо использовать.
messages список объектов-списокв запросов.
Вот по сути и весь код взаимодействия с ChatGPT API.
Навыки для Алисы
Что же такое навык для Алисы? Навык можно рассматривать как какую-то подпрограмму которая Алиса запускает, когда произносишь специальную фразу активации. Далее общение происходит напрямую с навыком, минуя Алису. Единственная фраза, которую она обрабатывает в этом режиме "Алиса, хватит".
Технически навык это выделенный post https endpoint. Алиса посылает POST запрос с текстом озвученным пользователем и ожидает ответ с тем текстом который она должна озвучить. Навыки можно создавать и на платформе Yandex Cloud Functions. Для этого надо зарегистрироваться на Yandex Cloud и создать платежный аккаунт. Но Cloud Functions для навыков Алисы не тарифицируется. К сожалению создание платежного аккаунта доступно только для пользователей из России и Казахстана. Поэтому мы будем делать наш собственный сервер. Для этого надо иметь сервер с IP доступным извне, доменом и включенным https. Будем считать что все это у нас уже есть.
Разработка сервера
Первая проблема вытекает именно от того что Алиса посылает запрос на наш сервер и ждет. Алиса ждет ровно 3 секунды. Если ответ не приходит за это время, Алиса скажет "Навык не отвечает" и просто выкинет вас из навыка. В это время входит и время отправки запроса и получения ответа, так что на обработку запроса остается совсем мало времени.
Понятно что генерация ответа у GPT займет больше. На этот случай после запроса пользователя мы просто просим подождать и позвать нас позже. Чтобы пользователю было не скучно можно например проиграть музыку, которую мы загрузили в навык. Однако навык не может инициализировать разговор, потому пользователь должен сказать что-то, чтобы активировать навык позже. Да это достаточно неудобно и приходится каждый раз спрашивать навык о том готов ли ответ.
Начнем с запуска сервера. Будем использовать FastAPI.
main.py
from fastapi import FastAPI, Request
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
@app.post("/post")
async def post(request: Request):
request = await request.json()
response = {
'session': request['session'],
'version': request['version'],
'session_state': request.get('state', {}).get('session', {}),
'response': {
'end_session': False
}
}
## Заполняем необходимую информацию
await handle_dialog(response, request)
print(response)
return response
Запускаем сервер вот так
uvicorn main:app --host 0.0.0.0 --port 5000
Тут мы просто написали обработчик POST json запросов. Получаем запрос
(https://yandex.ru/dev/dialogs/alice/doc/request.html). Подготавливаем словарь, который будем возвращать с нашим ответом (https://yandex.ru/dev/dialogs/alice/doc/response.html).
Подробнее о запросах и ответах можно прочитать по данным ссылкам. Скажу только что в большинстве случаев нам будет нужен только текст запроса пользователя. request['request']['original_utterance']. А ответ мы вернем в response['response']['text']. Конечно в запросе есть и другие поля. Яндекс проводит обработку текста запроса и возвращает это нам, например можно получить именованные сущности которые назвал пользователь в своем запросе, например имена или адреса, или интенты.
session_state позволяет хранить данным между запросами к навыку, на первое время этого будет достаточно и так мы сможем сохранять контекст и поддерживать беседу с пользователем.
Функция handle_dialog будет отвечать за обработку запроса и отправку ответа от chatGPT.
CUT_WORD = ['Алиса', 'алиса']
answers = dict()
async def handle_dialog(res,req):
if req['request']['original_utterance']:
# подтягиваем предыдущие сообщения от пользователя, которых мы сохранили в навыке
session_state = res.get('session_state', {})
messages = session_state.get('messages', [])
# получаем текст запроса от пользователя
request = req['request']['original_utterance']
# Если Алиса была активирована то мы случайно может отправить Алиса первым словом в запросе
for word in CUT_WORD:
request = request.lstrip(word)
request = request.strip()
# Если мы уже ответили на все вопросы то слушаем текущий вопрос
if 'message' not in session_state:
# асинхронно обращаемся к chatGPT
task = asyncio.create_task(ask(request, messages))
# Ждем в призрачной надежде что апи успеет дать ответ за 1 секунду
await asyncio.sleep(1)
# сохраняем контекст предыдыущих запросов в навыке
messages.append(request)
session_state['messages'] = messages
if task.done():
# Если мы успели получить ответ просто отвечаем пользователю
reply = task.result()
del answers[request]
else:
reply = 'Не успел получить ответ. Спросите позже'
session_state['message'] = request
else:
# Если мы не успели ответить на предыдущий вопрос то игнорируем ввод пользователя
# пока не ответим на предыдущий вопрос old_request
old_request = session_state['message']
# ответа все еще нет :(
if old_request not in answers:
reply = 'Ответ пока не готов, спросите позже'
else:
# Ответ на предыдущий вопрос готов.
# возвращаем его пользователю
answer = answers[old_request]
del answers[old_request]
del session_state['message']
reply = f'Отвечаю на предыдущий вопрос "{old_request}"\n {answer}'
else:
## Если это первое сообщение — представляемся
reply = 'Я умный chat бот. Спроси что-нибудь'
res['response']['text'] = reply
req['request']['original_utterance'] может быть пустым. Это значит что пользователь только активировал навык. Приветствуем его.
Мы завели словарь answer. В этом словаре ключ - вопрос пользователя, а значение ответ нейросети. Конечно лучше хранить сессии пользователя вместо этого и не памяти а, например, на Redis.
В коде мы запрашиваем ответ у chatGPT если он не уложился в 1 секунду, то отвечаем пользователю, что пока не готовы и предлагаем спросить нас позже.
Все следующие запросы пользователя мы игнорируем. Они нужны нам только для того что активировать запрос навыка, так как навык не может инициировать разговор.
Если при повторном запросе ответ все еще не готов мы снова просим повторить запрос позже.
Также мы храним историю предыдущих запросов.
Если с момента предыдущего запроса к навыку прошло достаточно времени лампочка на Алисе тухнет и чтобы задать следующий вопрос мы должны ее разбудить. Очевидно единственный способ это позвать колонку и после этого задать вопрос. Однако, если мы не смотрим на колонку, мы не узнаем активирована она еще или нет. Именно поэтому мы используем CUT_WORD. Мы просто удаляем Имя колонки из начала запроса. ChatGPT это видеть незачем.
Ну и последняя функция.
async def ask(request, messages):
try:
reply = await gpt.aquery(request, messages)
except Exception as e:
traceback.print_exc()
reply = 'Не удалось получить ответ'
answers[request] = reply
return reply
Это обертка над нашей функцией оращения к GPT-3 модели. Обрабатывает ошибки и обновляет наш словарь answers.
В приципе это весь код, его можно найти на Github.
Публикация навыка
Дальше нам надо опубликовать навык. Наш обработчик дожне быть доступен с серверов яндекса по https. Кроме того сервер дожен иметь публичный ip.
Подробнее о том как подключить новый навык на платформе диалогов Яндекса хорошо написано в инструкции
В качестве Webhook URL используем URL нашего обработчика
Если мы не хотим публиковать навык мы можем сделать его приватным. В этом случае даже не надо отправлять его на модерацию и навыком сможете воспользоваться только вы или те у кого есть специальное приглашение.
Особое внимание следует уделить Активационные имена и Примеры запросов. Это список фраз которые мы можем использовать, чтобы запустить навык. Имена должны быть достаточно уникальными с одной стороны чтобы не совпадать с фразами других возможных навыков. С другой стороны Алиса должна различать эти фразы и правильно их воспринимать не путая с другими словами. Если не соблюдать это правило вы потратите много времени доказывая Алисе, что вы хотите запустить.
Вот и все наш первый навык готов.
В конце прикреплю пару примеров работы навыка.
Скриншоты из мобильного приложения
Заключение
Вот несколько выводов которые зотелось бы указать в заключении.
Делать навыки для алисы очень просто, мы просто должны правильно обрабатывать запросы.
Алиса имеет ограничение на время запроса в три секунды. Если не укладываемся на UX взаимодействия сильно падает и мы должны просить пользователя активировать нас позже.
Имеется ограничение на длину ответа в 1024 символов. Мы должны сделать пагинацию для для длинных ответов.
Алиса не сможет прочитать нам код, потому запросы на генерацию кода лучше делать на сайте ChatGPT.
Навык должен отвечать быстро и кратко, голосове взаимодействие занимает много времени и не надо отнимать его у пользователя еще больше