Создание stateful навыка для Алисы на serverless функциях Яндекс.Облака и Питоне
Начнём с новостей. Вчера Яндекс.Облако анонсировало запуск сервиса бессерверных вычислений Yandex Cloud Functions. Это значит: ты пишешь только код своего сервиса (например, веб-приложения или чатбота), а Облако само создаёт и обслуживает виртуальные машины, где он запускается, и даже реплицирует их, если возрастает нагрузка. Думать вообще не надо, очень удобно. И плата идёт только за время вычислений.
Впрочем, кое-кто может вообще не платить. Это — разработчики внешних навыков Алисы, то есть встроенных в неё чатботов. Написать, захостить и зарегистрировать такой навык может любой разработчик, а с сегодняшнего дня навыки даже не надо хостить — достаточно залить их код в облако в виде той самой бессерверной функции.
Но есть пара нюансов. Во-первых, ваш питнячий код может требовать каких-то зависимостей, и затаскивать их в Облако — нетривиально. Во-вторых, любому нормальному чатботу нужно хранить где-то состояние диалога (stateful поэтому); как сделать это в бессерверной функции проще всего? В третьих, а как вообще можно быстро-грязно написать навык для Алисы или вообще какого-то бота с ненулевым сюжетом? Об этих нюансах, собственно, статья.
Моральная подготовка
Для нетерпеливых: необходимые зависимости я собираю make-файлом перед загрузкой функции в облако, состояние диалога храню в яндексовском Object Storage (оно поддерживает API S3), а для управления диалогом использую собственную библиотеку tgalice. В итоге получается такой демо-навык. А теперь разберём всё это чуть детальнее.
Для чуть менее нетерпеливых: прежде чем погружаться в мою историю про сложности, я очень рекомендую начать с простого. А именно, создать однофайловую функцию-эхобота, зарегистрировать и потестить её в консоли разработчика Диалогов; подробности — в блоге Диалогов и документации Облака. И только потом уже приступать.
Если хочется сразу ощутить, за что мы тут боремся, попросите Алису ‘включи навык «айтишный гороскоп»‘. Должен произойти примерно следующий диалог:
Алиса: Привет! Вы находитесь в навыке «Айтишный гороскоп». Скажите «Старт», чтобы узнать, что сулят вам звёзды.
Юзер: Старт
А: Пожалуйста, назовите своё имя
Ю: Васисуалий
А: Теперь скажите мне год вашего рождения. Только четыре цифры, ничего лишнего.
Ю: второй
А: Пожалуйста, попробуйте ещё раз. Назовите год вашего рождения — четыре цифры.
Ю: 2002
А: Замечательно! Теперь назовите месяц вашего рождения.
Ю: Феврарь
А: Отлично! Наконец, назовите мне дату вашего рождения — только число, всего одна или две цифры.
Ю: 18
А: Спасибо, Васисуалий! Теперь мы знаем: вам 17 лет, и вы Водолей. Вот это вам, конечно, повезло! Звёзды говорят вам: а что говорят звёзды, вы узнаете, запустив навык.
Проблема в том, что для поддержания даже такого простого диалога нужно помнить имя и дату рождения пользователя, а в среде бессерверных функций это нетривиально. Хранить контекст в оперативной памяти или файликом на диске не получится, т.к. Яндекс.Облако может запустить функцию на нескольких виртуальных машинах одновременно и переключаться между ними произвольным образом. Придётся воспользоваться каким-то внешним хранилищем. Выбрано было Object Storage, как довольно недорогое и несложное хранилище прямо в Яндекс.Облаке (т.е. наверное быстрое). В качестве бесплатной альтернативы можно попробовать, например, халявный кусочек облачной Монги где-то далеко. И для Object Storage (он поддерживает интерфейс S3), и для Mongo существуют удобные питоновские обёртки.
Другая проблема — что для хождения и в Object Storage, и в MongoDB, и в любую другую базу или хранилище данных, нужны какие-то внешние зависимости, которые нужно залить на Yandex Functions вместе с кодом своей функции. И хотелось бы делать это удобно. Совсем удобно (типа как на heroku), увы, не получится, но какой-то базовый комфорт можно создать, написав скрипт для сборки окружения (make-файл).
Как запустить навык-гороскоп
Подготовиться: зайти на какую-нибудь машинку с линуксом. В принципе, с Windows тоже, наверное, можно работать, но с запуском make-файла тогда придётся поколдовать. И в любом случае, вам понадобится установленный Python не ниже 3.6.
Создать себе два бакета в Object Storage, назвать их любым именем {BUCKET NAME} и tgalice-test-cold-storage (вот это второе имя сейчас захардкожено в main.py моего примера). Первый бакет нужен будет только для деплоя, второй — для хранения состояний диалога.
Создать сервисный аккаунт, дать ему роль editor, и получить к нему статические креденшалы {KEY ID} и {KEY VALUE} — их будем использовать для записи состояния диалога. Всё это нужно, чтобы функция из Я.Облака могла получить доступ к хранилищу из Я.Облака. Когда-нибудь, надеюсь, авторизация станет автоматической, но пока — так.
(Не обязательно) установить интерфейс командной строкиyc. Создать функцию можно и через веб-интерфейс, но CLI хорош тем, что всякие нововведения появляются в нём быстрее.
Теперь можно, собственно, подготовить сборку зависимостей: запустить в командной строке из папки с примером навыка make all. Установится куча библиотек (в основном, как обычно, ненужных) в папку dist.
Ручками залить в Object Storage (в бакет {BUCKET NAME}) получившийся на предыдущем шаге архив dist.zip. При желании, можно сделать это и из командной строки, например, используя AWS CLI.
Создать бессерверную функцию через веб-интерфейс или используя утилиту 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
При ручном создании функции все параметры заполняются аналогично.
Теперь созданную вами функцию можно тестировать через консоль разработчика, а потом дорабатывать и публиковать навык.
Что там под капотом
Make-файл на самом деле содержит в себе довольно простой скрипт для установки зависимостей и их укладки в архив 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 также есть шаблон, поэтому целиковый диалоговый менеджер составлен из кусочков:
CascadeDialogManager работает просто: пробует применить к текущему состоянию диалога все свои составляющие по очереди, и выбирает первую уместную.
В качестве ответа на каждое сообщение диалоговый менеджер возвращает питонячий объект Response, который дальше можно сконвертировать в голый текст, или в сообщение в Алисе или Телеграме — смотря где бот запущен; в нём же содержится и изменённое состояние диалога, которое нужно сохранить. Всей этой кухней занимается ещё один класс, DialogConnector, поэтому непосредственный скрипт для запуска навыка на Yandex Functions выглядит так:
Как видите, большая часть этого кода создаёт подключение к 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, я думал о каком-то промежуточном пути. Посмотрим, что из этого получится.