Создание stateful навыка для Алисы на serverless функциях Яндекс.Облака и Питоне

Начнём с новостей. Вчера Яндекс.Облако анонсировало запуск сервиса бессерверных вычислений Yandex Cloud Functions. Это значит: ты пишешь только код своего сервиса (например, веб-приложения или чатбота), а Облако само создаёт и обслуживает виртуальные машины, где он запускается, и даже реплицирует их, если возрастает нагрузка. Думать вообще не надо, очень удобно. И плата идёт только за время вычислений.

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

Но есть пара нюансов. Во-первых, ваш питнячий код может требовать каких-то зависимостей, и затаскивать их в Облако — нетривиально. Во-вторых, любому нормальному чатботу нужно хранить где-то состояние диалога (stateful поэтому); как сделать это в бессерверной функции проще всего? В третьих, а как вообще можно быстро-грязно написать навык для Алисы или вообще какого-то бота с ненулевым сюжетом? Об этих нюансах, собственно, статья.

Создание stateful навыка для Алисы на serverless функциях Яндекс.Облака и Питоне

Моральная подготовка

Для нетерпеливых: необходимые зависимости я собираю make-файлом перед загрузкой функции в облако, состояние диалога храню в яндексовском Object Storage (оно поддерживает API S3), а для управления диалогом использую собственную библиотеку tgalice. В итоге получается такой демо-навык. А теперь разберём всё это чуть детальнее.

Для чуть менее нетерпеливых: прежде чем погружаться в мою историю про сложности, я очень рекомендую начать с простого. А именно, создать однофайловую функцию-эхобота, зарегистрировать и потестить её в консоли разработчика Диалогов; подробности — в блоге Диалогов и документации Облака. И только потом уже приступать.

Если хочется сразу ощутить, за что мы тут боремся, попросите Алису ‘включи навык «айтишный гороскоп»‘. Должен произойти примерно следующий диалог:

Алиса: Привет! Вы находитесь в навыке «Айтишный гороскоп». Скажите «Старт», чтобы узнать, что сулят вам звёзды.
Юзер: Старт
А: Пожалуйста, назовите своё имя
Ю: Васисуалий
А: Теперь скажите мне год вашего рождения. Только четыре цифры, ничего лишнего.
Ю: второй
А: Пожалуйста, попробуйте ещё раз. Назовите год вашего рождения — четыре цифры.
Ю: 2002
А: Замечательно! Теперь назовите месяц вашего рождения.
Ю: Феврарь
А: Отлично! Наконец, назовите мне дату вашего рождения — только число, всего одна или две цифры.
Ю: 18
А: Спасибо, Васисуалий! Теперь мы знаем: вам 17 лет, и вы Водолей. Вот это вам, конечно, повезло! Звёзды говорят вам: а что говорят звёзды, вы узнаете, запустив навык.

Проблема в том, что для поддержания даже такого простого диалога нужно помнить имя и дату рождения пользователя, а в среде бессерверных функций это нетривиально. Хранить контекст в оперативной памяти или файликом на диске не получится, т.к. Яндекс.Облако может запустить функцию на нескольких виртуальных машинах одновременно и переключаться между ними произвольным образом. Придётся воспользоваться каким-то внешним хранилищем. Выбрано было Object Storage, как довольно недорогое и несложное хранилище прямо в Яндекс.Облаке (т.е. наверное быстрое). В качестве бесплатной альтернативы можно попробовать, например, халявный кусочек облачной Монги где-то далеко. И для Object Storage (он поддерживает интерфейс S3), и для Mongo существуют удобные питоновские обёртки.

Другая проблема — что для хождения и в Object Storage, и в MongoDB, и в любую другую базу или хранилище данных, нужны какие-то внешние зависимости, которые нужно залить на Yandex Functions вместе с кодом своей функции. И хотелось бы делать это удобно. Совсем удобно (типа как на heroku), увы, не получится, но какой-то базовый комфорт можно создать, написав скрипт для сборки окружения (make-файл).

Как запустить навык-гороскоп

  1. Подготовиться: зайти на какую-нибудь машинку с линуксом. В принципе, с Windows тоже, наверное, можно работать, но с запуском make-файла тогда придётся поколдовать. И в любом случае, вам понадобится установленный Python не ниже 3.6.
  2. Склонировать себе с гитхаба пример гороскопного навыка.
  3. Зарегистрироваться в Я.Облаке: https://cloud.yandex.ru
  4. Создать себе два бакета в Object Storage, назвать их любым именем {BUCKET NAME} и tgalice-test-cold-storage (вот это второе имя сейчас захардкожено в main.py моего примера). Первый бакет нужен будет только для деплоя, второй — для хранения состояний диалога.
  5. Создать сервисный аккаунт, дать ему роль editor, и получить к нему статические креденшалы {KEY ID} и {KEY VALUE} — их будем использовать для записи состояния диалога. Всё это нужно, чтобы функция из Я.Облака могла получить доступ к хранилищу из Я.Облака. Когда-нибудь, надеюсь, авторизация станет автоматической, но пока — так.
  6. (Не обязательно) установить интерфейс командной строки yc. Создать функцию можно и через веб-интерфейс, но CLI хорош тем, что всякие нововведения появляются в нём быстрее.
  7. Теперь можно, собственно, подготовить сборку зависимостей: запустить в командной строке из папки с примером навыка make all. Установится куча библиотек (в основном, как обычно, ненужных) в папку dist.
  8. Ручками залить в Object Storage (в бакет {BUCKET NAME}) получившийся на предыдущем шаге архив dist.zip. При желании, можно сделать это и из командной строки, например, используя AWS CLI.
  9. Создать бессерверную функцию через веб-интерфейс или используя утилиту yc. Для утилиты команда будет выглядеть вот так:

yc serverless function version create
    --function-name=horoscope
    --environment=AWS_ACCESS_KEY_ID={KEY ID},AWS_SECRET_ACCESS_KEY={KEY VALUE}
    --runtime=python37
    --package-bucket-name={BUCKET NAME}
    --package-object-name=dist.zip
    --entrypoint=main.alice_handler
    --memory=128M
    --execution-timeout=3s

При ручном создании функции все параметры заполняются аналогично.

Теперь созданную вами функцию можно тестировать через консоль разработчика, а потом дорабатывать и публиковать навык.

Создание stateful навыка для Алисы на serverless функциях Яндекс.Облака и Питоне

Что там под капотом

Make-файл на самом деле содержит в себе довольно простой скрипт для установки зависимостей и их укладки в архив dist.zip, приблизительно такой:

mkdir -p dist/
pip3 install -r requirements.txt --target dist/ 
cp main.py dist/main.py
cp form.yaml dist/form.yaml
cd dist && zip --exclude '*.pyc' -r ../dist.zip ./*

Остальное — несколько простых инструментов, завёрнутых в библиотеку tgalice. Процесс заполнения данных о юзере описывается конфигом form.yaml:

form_name: 'horoscope_form'
start:
  regexp: 'старт|нач(ать|ни)'
  suggests:
    - Старт
fields:
  - name: 'name'
    question: Пожалуйста, назовите своё имя.
  - name: 'year'
    question: Теперь скажите мне год вашего рождения. Только четыре цифры, ничего лишнего.
    validate_regexp: '^[0-9]{4}$'
    validate_message: Пожалуйста, попробуйте ещё раз. Назовите год вашего рождения - четыре цифры.
  - name: 'month'
    question: Замечательно! Теперь назовите месяц вашего рождения.
    options:
      - январь
     ...
      - декабрь
    validate_message: То, что вы назвали, не похоже на месяц. Пожалуйста, назовите месяц вашего рождения, без других слов.
  - name: 'day'
    question: Отлично! Наконец, назовите мне дату вашего рождения - только число, всего одна или две цифры.
    validate_regexp: '[0123]?d$'
    validate_message: Пожалуйста, попробуйте ещё раз. Вам нужно назвать число своего рождения (например, двадцатое); это одна или две цифры.

Работу по разбору этого конфига и вычислению финального результата берёт на себя питонячий класс

class CheckableFormFiller(tgalice.dialog_manager.form_filling.FormFillingDialogManager):
    SIGNS = {
        'январь': 'Козерог',
        ...
    }

    def handle_completed_form(self, form, user_object, ctx):
        response = tgalice.dialog_manager.base.Response(
            text='Спасибо, {}! Теперь мы знаем: вам {} лет, и вы {}. n'
                 'Вот это вам, конечно, повезло! Звёзды говорят вам: {}'.format(
                form['fields']['name'],
                2019 - int(form['fields']['year']),
                self.SIGNS[form['fields']['month']],
                random.choice(FORECASTS),
            ),
            user_object=user_object,
        )
        return response

Точнее, базовый класс FormFillingDialogManager занимается заполнением «формы», а метод дочернего класса handle_completed_form говорит, что делать, когда она готова.

Кроме этого основного потока диалога пользователя надо ещё попривествовать, а также выдать справку по команде «помощь» и выпустить из навыка по команде «выход». Для этого в tgalice также есть шаблон, поэтому целиковый диалоговый менеджер составлен из кусочков:

dm = tgalice.dialog_manager.CascadeDialogManager(
    tgalice.dialog_manager.GreetAndHelpDialogManager(
        greeting_message=DEFAULT_MESSAGE,
        help_message=DEFAULT_MESSAGE,
        exit_message='До свидания, приходите в навык "Айтишный гороскоп" ещё!'
    ),
    CheckableFormFiller(`form.yaml`, default_message=DEFAULT_MESSAGE)
)

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

В качестве ответа на каждое сообщение диалоговый менеджер возвращает питонячий объект Response, который дальше можно сконвертировать в голый текст, или в сообщение в Алисе или Телеграме — смотря где бот запущен; в нём же содержится и изменённое состояние диалога, которое нужно сохранить. Всей этой кухней занимается ещё один класс, DialogConnector, поэтому непосредственный скрипт для запуска навыка на Yandex Functions выглядит так:

...
session = boto3.session.Session()
s3 = session.client(
    service_name='s3',
    endpoint_url='https://storage.yandexcloud.net',
    aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
    aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY'],
    region_name='ru-central1',
)
storage = tgalice.session_storage.S3BasedStorage(s3_client=s3, bucket_name='tgalice-test-cold-storage')
connector = tgalice.dialog_connector.DialogConnector(dialog_manager=dm, storage=storage)
alice_handler = connector.serverless_alice_handler

Как видите, большая часть этого кода создаёт подключение к S3-интерфейсу Object Storage. Как непосредственно используется это подключение, можно почитать в коде tgalice.
Последняя строчка создаёт функцию alice_handler — ту самую, которую мы велели дёргать Яндекс.Облаку, когда задавали параметр --entrypoint=main.alice_handler.

Вот, собственно, и всё. Make-файлы для сборки, S3-подобное Object Storage для хранения контекста, и питонячья библиотека tgalice. Вкупе с бессерверными функциями и выразительностью питона этого достаточно для разработки навыка здорового человека.

Вы можете спросить, зачем нужна понадобилось создавать tgalice? Весь скучный код, перекладывающий JSON’ы из запроса в ответ и из хранилища в память и обратно, лежит в ней. Там же лежит применялка регулярок, функция для понимания того, что «феврарь» похоже на «февраль», и прочее NLU для бедных. По моей задумке, этого уже должно быть достаточно, чтобы можно было набрасывать прототипы навыков в yaml-файлах, не слишком отвлекаясь на технические детали.

Если хочется более серьёзного NLU, можно прикрутить к своему навыку Rasa или DeepPavlov, но для их настройки потребуются дополнительные пляски с бубном, особенно на serverless. Если совсем не хочется кодить, стоит воспользоваться визуальным конструктором типа Aimylogic. Создавая tgalice, я думал о каком-то промежуточном пути. Посмотрим, что из этого получится.

Ну а нынче вступайте в чат разработчиков алисьих навыков, читайте документацию, и создавайте замечательные навыки!

Источник: habr.com