Как быстро и бесшовно встроить голосовой интерфейс в ваше мобильное приложение? И как научить ассистента всему, что оно умеет? В прошлый раз мы взяли опенсорсное лайфстайл-приложение Habitica и показали, как добавить в него помощника и запилить базовый голосовой сценарий «из коробки» (уточнение прогноза погоды и времени). А теперь перейдем к более продвинутому этапу – научимся вызывать голосом определенные экраны, делать сложные запросы с NLU и form-filling с помощью голоса внутри приложения.

(Читать первую часть туториала)

Итак, Habitica – это приложение для выработки хороших привычек с элементами геймификации: поддержание ваших жизненных целей в виде привычек, ежедневных дел и задач поощряется наградами. И сейчас мы научим голосового ассистента, которого сами же в приложение и поселили, как создавать и заполнять таски, вредные привычки и награды голосом, а не вручную.

Логика голосового интерфейса

Начнем с самого простого – логики на стороне приложения. Мы хотим по голосовой команде открывать, например, настройки или окно изменения характеристик. Открываем AndroidManifest и находим соответствующие активити. Находим PrefsActivity, который отвечает за настройки, FixCharacterValuesActivity, который отвечает за изменение характеристик персонажа, и до кучи находим активити, по которой открывается профиль и информация о приложении, FullProfileActivity и AboutActivity.

Согласно документации, нам нужно вносить клиентскую логику в класс, наследуемый от CustomSkill. Во-первых, укажем, что нам нужно реагировать только на ответ от бота, содержащий в response.action changeView”. В response.intent мы будем передавать непосредственно команду, куда именно переходить – и в зависимости от этого вызывать активити. Ну и не забудем перед этим найти контекст приложения:

class ChangeViewSkill(private val context: Context): CustomSkill<AimyboxRequest, AimyboxResponse> {

    override fun canHandle(response: AimyboxResponse) = response.action == "changeView"

    override suspend fun onResponse(
            response: AimyboxResponse,
            aimybox: Aimybox,
            defaultHandler: suspend (Response) -> Unit
    ) {
        val intent = when (response.intent) {
            "settings" -> Intent(context, PrefsActivity::class.java)
            "characteristics" -> Intent(context, FixCharacterValuesActivity::class.java)//
            "profile" -> Intent(context, FullProfileActivity::class.java)//
            "about" -> Intent(context, AboutActivity::class.java)
            else -> Intent(context, MainActivity::class.java)
        }
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        aimybox.standby()
        context.startActivity(intent)
    }
}

Этот скилл добавляется к ассистенту следующим образом:

 val dialogApi = AimyboxDialogApi(
                "YOUR KEY HERE", unitId,
            customSkills = linkedSetOf(ChangeView()))

Навык и интенты

Навык мы будем писать на JAICF (это опенсорсный и совершенно бесплатный фреймворк для разработки голосовых приложений от Just AI на Kotlin).

Форкаем себе https://github.com/just-ai/jaicf-jaicp-caila-template.

К сожалению, на момент написания статьи на платформе JAICP (Just AI Conversational Platform) еще не было интеграции c Aimybox (SDK для построения диалоговых интерфейсов), иначе подключение было бы намного более простым – просто через добавление одной строчки в один из двух файлов подключений в папке connections. А пока делаем новый файл подключения, который мы будем запускать для тестов. Создаем файл AimyboxConnection.

package com.justai.jaicf.template.connections
import com.justai.jaicf.channel.http.httpBotRouting
import com.justai.jaicf.channel.aimybox.AimyboxChannel
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import com.justai.jaicf.template.templateBot

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

Для того, чтобы пользоваться NLU-функционалом, подключаем NLU-сервис Caila – для этого регистрируемся на app.jaicp.com, в настройках находим ключ API и прописываем его в conf/jaicp.properties. Теперь мы можем прямо в сценарии ссылаться на интенты, которые пропишем на app.jaicp.com.

Можно воспользоваться любым другим NLU-функционалом или обойтись регулярными выражениями – но для того, чтобы сделать все красиво и просто для пользователя, лучше пользоваться NLU.

Для начала заведем интенты. Нам нужно распознавать, что пользователь хочет перейти в определенный раздел приложения. Для этого в сущностях мы заводим сущность под каждый из разделов, добавляя синонимы, и в DATA прописываем то, как мы будем распознавать это уже на уровне приложения (settings, characteristics, и т.д. из кода выше).

У меня получилось вот так:

Дальше прописываем то, как именно мы ожидаем встретить эту сущность во фразах пользователя. Для этого создаем интент и прописываем там вариации фраз. Кроме того, так как для перехода нам обязательно нужно знать, куда переходить, прописываем, что содержание сущности views во фразе обязательное. У меня получилось так.

По названию мы потом будем отсылать к этому интенту в коде JAICF.

Чтобы удостовериться, что интенты распознаются как надо, можно сразу ввести несколько тест-фраз по кнопке «Тестирование» . Вроде все ок.

Сценарий: вызываем скилл

Я на всякий случай потер все стандартные стейты, оставив только catchAll – то, что бот говорит, если он нас не понимает. Создаем стейт changeView, в activators прописываем созданный нами в JAICP интент, а в actions прописываем логику – нам нужно добавить в ответ бота, в стандартные реакции канала Aimybox всю информацию для того, чтобы сделать переход.

Просто достаем слот views из того, что распознала Caila, прописываем в action то, что мы прописали ранее, чтобы Aimybox знал, какой скилл запустить, и отправляем распознанный слот в интенте. Для красоты добавляем туда «Перехожу». Все-таки ж чатбот.

        state("changeView") {
            activators {
                intent("changeView")
            }
            action {
                reactions.say("Перехожу..." )
                var slot = ""
                activator.caila?.run {slot = slots["views"].toString()}
                reactions.aimybox?.response?.action = "changeView"
                reactions.aimybox?.response?.intent = slot

            }
        }

Скиллы лучше выносить в отдельный пакет skills с фаликом класса под каждый скилл.
Дальше вариантов несколько. Можно поднять бота локально через ngrok, можно воспользоваться heroku. Получившуюся ссылку прокидываем в app.aimybox.com, через создание там кастомного навыка, в поле Aimylogic webhook URL. В примеры пишем пару примеров вызова: открой настройки, открой инфо.

После подключения канала можно проверить выдачу прямо в консоли, чтобы отловить баги, по кнопке Try in Action.

Можно подключить скилл напрямую, без консоли и дополнительных навыков – как, описано тут.

Вроде все передается правильно. Попробуем в приложении. Весь код уже готов, осталось только запустить и попробовать.

Работает! Теперь самое сложное.

Заполняем задачи голосом

Хочется одной командой заполнить задачку, проверить, что все правильно, исправить какие-то небольшие ошибки (все-таки распознавание не всегда работает идеально), и только после этого создать ее окончательно.

Для этого сделаем второй скилл. Будем отличать его от первого через response.action == "createTask", а то, какой конкретно тип задачки создается через response.intent. 

Изучив сорцы приложения, понимаешь, что и награды, и дэйлики, и привычки, и задачки создаются через TaskFormActivity, просто с разными типами. Для начала пропишем эту логику.

class CreateTaskSkill(private val context: Context): CustomSkill<AimyboxRequest, AimyboxResponse> {


    override fun canHandle(response: AimyboxResponse) = response.action == "createTask"

    override suspend fun onResponse(
            response: AimyboxResponse,
            aimybox: Aimybox,
            defaultHandler: suspend (Response) -> Unit
    ) {
        val intent = Intent(context, TaskFormActivity::class.java)
        val additionalData = HashMap<String, Any>()
        val type = response.intent
        additionalData["viewed task type"] = when (type) {
            "habit" -> Task.TYPE_HABIT
            "daily" -> Task.TYPE_DAILY
            "todo" -> Task.TYPE_TODO
            "reward" -> Task.TYPE_REWARD
            else -> ""
        }

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

Передавать их мы будем через response.data, если они будут нулевыми, проставим стандартное описание.

Забандлим полученные данные и запустим таску с этим бандлом. Не забудем добавить обработку забандленного кода в onCreate TaskFormActivity.

// Inserted code for voice activation
        textEditText.setText(bundle.getString("activity_name")) // presetting task name
        notesEditText.setText(bundle.getString("activity_description")) //presetting task description
        if (bundle.getBoolean("sentiment")) {  // presetting task sentiment
            habitScoringButtons.isPositive = true
            habitScoringButtons.isNegative = false
        } else {
            habitScoringButtons.isNegative = true
            habitScoringButtons.isPositive = false
        }
        when (bundle.getString("activity_difficulty").toString()) { // presetting task difficulty
            "trivial" -> taskDifficultyButtons.selectedDifficulty = 0.1f
            "easy" -> taskDifficultyButtons.selectedDifficulty = 1f
            "medium" -> taskDifficultyButtons.selectedDifficulty = 1.5f
            "hard" -> taskDifficultyButtons.selectedDifficulty = 2f
            else -> taskDifficultyButtons.selectedDifficulty = 1f
        }

Теперь настроим распознавание и передачу в коде JAICF и в Caila.

Готовим Caila: заводим сущность под распознавание типов тасок, сложности и вредности (для примера я завел их с помощью паттернов, для этого нужно выбрать Pattern вместо синонимов в левой части формы).

Не забываем в data прописать данные, которые мы будем обрабатывать на клиентской стороне – habit, pattern и так далее.

Так как название и описание может быть любым, создадим сущности Name и Description, в которой пропишем регулярное выражение, матчащее любое слово. Пока что у нас в названии и описании будет по одному слову.

Делаем интент:

Указываем, что нам обязательно нужен task_type и сложность. Можем добавить в обязательные и название, и описание – тогда, если пользователь не скажет одно или другое, бот уточнит у него с помощью вопроса слот, который еще не указан.

Прописываем разные вариации того, как можно задать название и описание вместе с типом (порядок, отсутствие одного или другого). Тут нет предела совершенству, но для минимума достаточно шаблонов выше.

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

@ – шаблоны и регулярки, – примеры и семантическая близость.
Теперь сценарий в JAICF.

state("createTask") {
            activators {
                intent("createTask")
            }
            action {
                val taskType = activator.getCailaSlot("taskType").asJsonLiteralOr("")
                reactions.say("Перехожу...")
                reactions.aimybox?.response?.action = "createTask"
                reactions.aimybox?.response?.intent = taskType.content
                reactions.aimybox?.response?.run {
                    data["taskName"] = activator.getCailaSlot("taskName").asJsonLiteralOr("")
                    data["taskDescription"] = activator.getCailaSlot("taskDescription").asJsonLiteralOr("")
                    data["taskSentiment"] = activator.getCailaSlotBool("taskSentiment").asJsonLiteralOr(true)
                    data["taskDifficulty"] = activator.getCailaSlot("taskDifficulty").asJsonLiteralOr("easy")
                }
            }
        } 
private fun ActivatorContext.getCailaRequiredSlot(k: String): String =
    getCailaSlot(k) ?: error("Missing Caila slot for key: $k")

private fun ActivatorContext.getCailaSlot(k: String): String? =
    caila?.slots?.get(k)

private fun ActivatorContext.getCailaSlotBool(k: String): Boolean? =
    caila?.slots?.get(k)?.toBoolean()

private fun String?.asJsonLiteralOr(other: String) = this?.let { JsonLiteral(this) } ?: JsonLiteral(other)
private fun Boolean?.asJsonLiteralOr(other: Boolean) = this?.let { JsonLiteral(this) } ?: JsonLiteral(other)

Подключаем интент через активатор, записываем из полученных слотов тип в intent, название и описание в data, и не забываем проставить action, чтобы Aimybox с клиентской стороны знал, какой скилл выбрать.

Проверяем, работает! Предлагаю включить звук и прочекать:

Да, это техническое демо – конечно, с точки зрения продукта можно придумать сценарии поудобнее. Но об этом в следующих статьях!
Ссылка на репозиторий с навыком JAICF.
Ссылка на репозиторий с кодом Aimybox.