При разработке ботов для Telegram и других месенджеров, периодически возникает задача распознавания и выполнения запросов, высказанных человеческим языком. Именно эта "фишка", по некоторому мнению, и является главным отличием ботов от приложений командной строки. Под катом описан собственный фреймворк для исполнения произвольных речевых команд. Описания ключевых концепций сопровождены примерами на языке Kotlin.
За основу для распознания речи возьмем нормализованные семантические представления. Их выбор обусловлен прежде всего простотой и легкостью реализации. Начнем с базиса, пример из исходников фреймворка:
/** Правило проверяет лексему на соответствие */typealias Rule = (String) -> Boolean/** Нормализованное семантическое представление */open class Semnorm(vararg val rules: Rule)/** Правило задает стемы для семантических представлений */fun stem(vararg stems: String): Rule = { stems.any(it::startsWith) }/** Правило задает точные соответствия для семантических представлений */fun word(vararg words: String): Rule = { words.any(it::equals) }/** Проверяем слово на соответствие семантике */fun String.matches(norm: Semnorm) = norm.rules.any { it(this) }
Теперь у нас появилась возможность задавать предопределенные нормализованные семантические представления в виде объектов:
object Day : Semnorm(stem("day", "суток", "сутк", "дня", "ден", "дне"))
Фреймворк ставит их в соответствие лексемам входящих фраз, и предложение начинает выглядеть, например так:
assertThat( "забань васю на 5 минут".tokenize(), equalTo( listOf( Token("забань", Ban), Token("васю", null), Token("на", null), Token("5", Number), Token("минут", Minute) ) ))
С распознаванием речи мы разобрались. Код токенизатора приложен в репозитории, доступном в конце статьи. Перейдем к исполнению команд из речи. А вот здесь и начинается самое интересное: фреймворк позволяет для каждого семантического представления навесить заданное поведение. Снова простейший пример, как распознать запрос справки на двух языках:
object Help : ExecutableSemnorm(stem( "помощ", "справк", "правил", "help", "rule", "faq", "start", "старт",)) { override fun execute(bot: Botm: Message) { val faq = message.from.relatedFaq() bot.sendMessage(m.chat.id, faq) }}
Что насчет более сложного поведения, зависящего от различных слов в предложении? Оно тоже поддерживается, вот как, например, исполняется, уже известное из тестов предложение забанить Васю:
object Ban : DurableSemonrm(stem( "ban", "block", "mute", "бан", "блок", "забан", "завали", "замьют",)) { override fun execute( bot: Bot, attackerMessage: Message, duration: Duration) { val victimMessage = attackerMessage.replyToMessage val victimId = victimMessage.from.id val untilSecond = now().epochSecond + duration.inWholeSeconds bot.restrictChatMember( attackerMessage.chat.id, victimId, untilSecond) }}
Откуда это семантическое представление знает о своей продолжительности? Дело в том, что ему вовсе не обязательно парсить всю цепочку токенов целиком. Достаточно задать только минимально необходимое поведение для каждого представления, например для времени:
object Week : Semnorm(stem("week", "недел")) { override fun toDuration(number: Long) = days(number) * 7}
Или для любых команд, зависящих от времени:
class DurableSemnorm(vararg rules: Rule) : ExecutableSemnorm(*rules) { final override fun execute( token: Iterator<Token>, bot: Bot, m: Message) = execute(bot, message, token.parseDuration()) abstract fun execute(bot: Bot, m: Message, duration: Duration)}
Благодаря такой архитектуре, нам больше не приходится думать о запутанной логике работы интерпретатора. Достаточно просто определить желаемые атрибуты для семантических представлений и наслаждаться результатом. Пример бота, использующего эту концепцию, можно посмотреть на Github: https://github.com/demidko/timecobot