О 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 символов. Мы должны сделать пагинацию для длинных ответов.

  • Алиса не сможет прочитать нам код, потому запросы на генерацию кода лучше делать на сайте.

  • Навык должен отвечать быстро и кратко, голосовое взаимодействие занимает много времени и не надо отнимать его у пользователя еще больше