В июне Яндекс устроил онлайн-хакатон среди разработчиков голосовых навыков. Мы в Just AI как раз обновляли наш опенсорсный фреймворк на Kotlin, чтобы поддержать новые прикольные фичи Алисы. И нужно было придумать какой-то простенький пример для README…

О том, как пара сотен строк кода на Kotlin превратилась в Яндекс.Станцию

Алиса + Kotlin = JAICF


У Just AI есть опенсорсный и совершенно бесплатный фреймворк для разработки голосовых приложений и текстовых чатботов — JAICF. Он написан на Kotlin — языке программирования от JetBrains, который хорошо знаком всем андроидщикам и серверятникам, которые пишут кровавый энтерпрайз (ну или переписывают его с Java). Фреймворк нацелен на то, чтобы облегчить создание именно диалоговых приложений для разных голосовых, текстовых и даже телефонных ассистентов.

У Яндекса есть Алиса — голосовой помощник с приятным голосом и открытым API для сторонних разработчиков. То есть любой девелопер может расширить функционал Алисы для миллионов пользователей и за это даже получить от Яндекса деньги.

Мы, конечно же, официально подружили JAICF с Алисой, так что теперь можно писать навыки на Kotlin. И вот как это выглядит.

Сценарий > Вебхук > Диалог



Любой Алисий навык — это голосовой диалог между пользователем и цифровым помощником. Диалог описывается в JAICF в виде сценариев, которые потом запускаются на сервере-вебхуке, который прописывается в Яндекс.Диалогах.

Сценарий


Возьмем навык, который мы придумали для хакатона. Он помогает экономить при покупках в магазинах. Сперва посмотрите, как он работает.


Тут можно увидеть, как пользователь спрашивает у Алисы — “Скажи что выгоднее — столько-то рублей за такое-то количество или столько-то за такое?”

Алиса тут же запускает наш навык (потому что он называется “Что выгоднее”) и передает в него всю необходимую информацию — интент пользователя и данные из его запроса.

Навык, в свою очередь, реагирует на интент, обрабатывает данные и возвращает полезный ответ. Алиса произносит ответ и отключается, потому что навык заканчивает сессию (это у них называется “однопроходный навык”).

Вот такой нехитрый сценарий, который, однако, позволяет быстро посчитать, насколько один товар выгоднее другого. Ну и заодно выиграть говорящую колонку от Яндекса.


Как же он выглядит на Kotlin?
object MainScenario: Scenario() {
    init {
        state("profit") {
            activators {
                intent("CALCULATE.PROFIT")
            }

            action {
                activator.alice?.run {
                    val a1 = slots["first_amount"]
                    val a2 = slots["second_amount"]
                    val p1 = slots["first_price"]
                    val p2 = slots["second_price"]
                    val u1 = slots["first_unit"]
                    val u2 = slots["second_unit"] ?: firstUnit

                    context.session["first"] = Product(a1?.value?.double ?: 1.0, p1!!.value.int, u1!!.value.content)
                    context.session["second"] = p2?.let {
                        Product(a2?.value?.double ?: 1.0, p2.value.int, u2!!.value.content)
                    }

                    reactions.go("calculate")
                }
            }

            state("calculate") {
                action {
                    val first = context.session["first"] as? Product
                    val second = context.session["second"] as? Product

                    if (second == null) {
                        reactions.say("А с чем сравнить?")
                    } else {
                        val profit = try {
                            ProfitCalculator.calculateProfit(first!!, second)
                        } catch (e: Exception) {
                            reactions.say("Тут сосчитать не могу, извините. Попробуйте еще разок.")
                            return@action
                        }

                        if (profit == null || profit.percent == 0) {
                            reactions.say("Тут разницы в цене вообще нет.")
                        } else {
                            val variant = when {
                                profit.product === first -> "Первый"
                                else -> "Второй"
                            }

                            var reply = "$variant вариант выгоднее "

                            reply += when {
                                profit.percent < 10 -> "всего лишь на ${profit.percent}%."
                                profit.percent < 100 -> "на ${profit.percent}%."
                                else -> "на целых ${profit.percent}%."
                            }

                            context.client["last_reply"] = reply
                            reactions.say(reply)
                            reactions.alice?.endSession()
                        }
                    }
                }
            }

            state("second") {
                activators {
                    intent("SECOND.PRODUCT")
                }

                action {
                    activator.alice?.run {
                        val a2 = slots["second_amount"]
                        val p2 = slots["second_price"]
                        val u2 = slots["second_unit"]

                        val first = context.session["first"] as Product
                        context.session["second"] = Product(
                            a2?.value?.double ?: 1.0,
                            p2!!.value.int,
                            u2?.value?.content ?: first.unit
                        )

                        reactions.go("../calculate")
                    }
                }
            }
        }

        fallback {
            reactions.say("Извините, не очень понятно. " +
                    "Можно спросить так: что выгоднее, 2 литра за 230 рублей или 3 литра за 400.")
        }
    }
}


С полным текстом сценария можно ознакомиться на Github.

Как видите, это обычный объект, который расширяет класс Scenario из библиотеки JAICF. По сути, сценарий — это стейт-машина, где каждый узел — это возможное состояние диалога. Так мы реализуем работу с контекстом, так как контекст диалога — это очень важная составляющая любого голосового приложения.

Скажем, одна и та же фраза может быть интерпретирована по-разному в зависимости от контекста диалога. Кстати, это одна из причин, почему мы выбрали именно Kotlin для нашего фреймворка — он позволяет создавать лаконичный DSL, в котором удобно управлять такими вот вложенными друг в друга контекстами и переходами между ними.

Стейт активируется с помощью активатора (например, интента) и выполняет вложенный в него блок кода — экшн. А внутри экшена можно делать все что угодно, но главное — вернуть пользователю какой-то полезный ответ или что-то доспросить. Это делается с помощью реакций. По ссылкам вы найдете подробнейшее описание каждой этой сущности.

Интенты и слоты




Интент — это языконезависимое представление пользовательского запроса. Собственно, это идентификатор того, что хочет пользователь добиться от вашего диалогового приложения.

Алиса недавно научилась автоматически определять интенты для вашего навыка, если вы перед этим опишете специальную грамматику. Более того, она умеет выделять из фразы нужные данные в виде слотов — например, цену и объем товара, как в нашем примере.

Чтобы все это заработало, нужно описать такую грамматику и слоты. Вот такая грамматика получилась в нашем навыке, и вот такие слоты мы в ней используем. Это и позволяет нашему навыку получать на вход не просто строчку пользовательского запроса на русском языке, а уже языконезависимый идентификатор и сконвертированные слоты впридачу (цену каждого товара и его объем).

JAICF, конечно же, поддерживает любой другой движок NLU (например, Caila или Dialogflow), но в нашем примере мы хотели использовать именно эту фичу Алисы, чтобы показать, как она работает.

Вебхук


Ну хорошо, сценарий у нас есть. Как нам проверить, что он работает?

Конечно же, адепты test-driven-development подхода оценят наличие в JAICF встроенного механизма автоматизированного тестирования диалоговых сценариев, которым мы лично пользуемся постоянно, так как делаем большие проекты, а проверять все изменения руками тяжело. Но наш пример совсем небольшой, поэтому мы лучше сразу запустим сервер и попробуем поговорить с Алисой.

Для запуска сценария нужен вебхук — сервер, который принимает входящие запросы от Яндекса, когда пользователь начинает говорить с вашим навыком. Сервер запустить совсем не сложно — нужно только сконфигурировать вашего бота и повесить на него какой-нибудь endpoint.

val skill = BotEngine(
    model = MainScenario.model,
    activators = arrayOf(
        AliceIntentActivator,
        BaseEventActivator,
        CatchAllActivator
    )
)

Вот так конфигурируется сам бот — тут мы описываем, какие сценарии в нем используются, где хранить данные пользователей и какие активаторы нам нужны для работы сценария (их может быть несколько).

fun main() {
    embeddedServer(Netty, System.getenv("PORT")?.toInt() ?: 8080) {
        routing {
            httpBotRouting("/" to AliceChannel(skill, useDataStorage = true))
        }
    }.start(wait = true)
}

А вот так просто запускается сервер с вебхуком — нужно только указать, какой канал на каком endpoint'е должен работать. Мы запустили тут сервер Ktor от JetBrains, но в JAICF можно использовать и любые другие.

Тут мы использовали еще одну фичу Алисы — хранение данных пользователя в ее внутренней базе данных (опция useDataStorage). JAICF будет автоматически сохранять и восстанавливать оттуда контекст и все, что туда запишет наш сценарий. Серриализация происходит прозрачно.

Диалог


Наконец-то мы можем все это протестировать! Сервер запускается локально, поэтому нам понадобится временный публичный URL, чтобы запросы из Алисы долетали до нашего вебхука из Интернета. Для этого удобно использовать бесплатный тул ngrok, просто запустив в терминале команду типа ngrok http 8080



Все запросы будут прилетать в режиме реального времени на ваш ПК — так вы можете дебажить и править код.

Теперь можно взять полученный https URL и указать его при создании нового Алисьего диалога на Яндекс. Диалогах. Там же можно протестировать диалог текстом. Но если хочется поговорить с навыком голосом, то теперь Алиса умеет быстро публиковать приватные навыки, которые на время разработки доступны только вам. Так можно, не проходя долгую модерацию от Яндекса, уже начать разговаривать со своим навыком прямо из приложения Алисы или с умной колонки.



Публикация


Мы все протестили и готовы опубликовать навык для всех пользователей Алисы! Чтобы это сделать, наш вебхук должен захоститься где-нибудь на публичном сервере с постоянным URL. В принципе, приложения на JAICF можно запускать где угодно, где поддерживается Java (хоть на Android смартфоне).

Наш пример мы запустили на Heroku. Просто создали новое приложение и прописали в нем адрес нашего Github-репозитория, где хранится код навыка. Heroku сам все собирает и запускает из исходников. Нам остается только прописать получившийся публичный URL в настройках Яндекс. Диалогов и отправить все это на модерацию.

Итого


Этот небольшой туториал получился по следам Яндексового хакатона, где приведенный выше сценарий “Что выгоднее” таки выиграл одну из трех Яндекс.Станций! Тут, кстати, можно посмотреть, как это было.

Фреймворк JAICF на Kotlin помог мне быстро имплементировать и отдебажить диалоговый сценарий, не заморачиваясь на работу с API Алисы, контекстами и базами данных, при этом не ограничивая в возможностях (как это часто бывает с подобными библиотеками).

Полезные ссылки


Полная дока по JAICF лежит здесь.
Инструкция по созданию на нем навыков для Алисы тут.
С исходником самого навыка можно ознакомиться там.

И если вам понравилось


Не стесняйтесь контрибутить в JAICF, как это уже делают коллеги из Яндекса, или просто оставить звездочку в Github.

А если появляются вопросы, мы на них тут же отвечаем в нашем уютном Slack.