О ChatGPT сейчас не говорит только ленивый. Но ему чего-то не хватает, например голоса. Давайте попробуем соединить голосовой помощник Алиса и ChatGPT. Таким образом мы сможем взаимодействовать с ChatGPT с помощью голоса. А он с помощью голоса может нам отвечать. Конечно тут будут ограничения. Я подробно опишу их дальше. Данная статья не столько о ChatGPT, сколько о том, как писать навыки для Алисы. Было интересно разобраться и написать такой навык за вечер.
ChatGPT API
Официальное API ChatGPT открыто и для его использования нужен только API_KEY c сайта OpenAI https://platform.openai.com/account/api-keys. Апи платное, есть триал и лимит бесплатного использования на первые три месяца. Сейчас,похоже, чтобы зайти туда нужен VPN.
Для взаимодействия будем пользоваться официальной питоновской библиотекой 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'] = replyreq['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 символов. Мы должны сделать пагинацию для длинных ответов.
Алиса не сможет прочитать нам код, потому запросы на генерацию кода лучше делать на сайте.
Навык должен отвечать быстро и кратко, голосовое взаимодействие занимает много времени и не надо отнимать его у пользователя еще больше
