Создание 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.
- Склонировать себе с гитхаба пример гороскопного навыка.
- Зарегистрироваться в Я.Облаке: https://cloud.yandex.ru
- Создать себе два бакета в 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
, приблизительно такой:
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, я думал о каком-то промежуточном пути. Посмотрим, что из этого получится.
Ну, а нынче вступайте в чат разработчиков алисьих навыков, читайте документацию, и создавайте замечательные навыки!