Как быстро и бесшовно встроить голосовой интерфейс в ваше мобильное приложение? И как научить ассистента всему, что оно умеет? В прошлый раз мы взяли опенсорсное лайфстайл-приложение 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.connectionsimport com.justai.jaicf.channel.http.httpBotRoutingimport com.justai.jaicf.channel.aimybox.AimyboxChannelimport io.ktor.routing.routingimport io.ktor.server.engine.embeddedServerimport io.ktor.server.netty.Nettyimport com.justai.jaicf.template.templateBotfun 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.