[Разработка мобильных приложений, Разработка под Android, Kotlin, Голосовые интерфейсы] Голос в мобильном приложении: учимся вызывать экраны и заполнять формы без рук

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
30-Сен-2020 18:32

Как быстро и бесшовно встроить голосовой интерфейс в ваше мобильное приложение? И как научить ассистента всему, что оно умеет? В прошлый раз мы взяли опенсорсное лайфстайл-приложение 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.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_razrabotka_pod_android (Разработка под Android), #_kotlin, #_golosovye_interfejsy (Голосовые интерфейсы), #_kotlin, #_jaicf, #_voice_assistant, #_mobile_apps, #_nlu, #_voice_ui, #_aimybox, #_mobile_development, #_blog_kompanii_just_ai (
Блог компании Just AI
)
, #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
)
, #_razrabotka_pod_android (
Разработка под Android
)
, #_kotlin, #_golosovye_interfejsy (
Голосовые интерфейсы
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 18:43
Часовой пояс: UTC + 5