О 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'] = 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 символов. Мы должны сделать пагинацию для длинных ответов.
Алиса не сможет прочитать нам код, потому запросы на генерацию кода лучше делать на сайте.
Навык должен отвечать быстро и кратко, голосовое взаимодействие занимает много времени и не надо отнимать его у пользователя еще больше
