Привет, Хабр! Увлёкся я навыками для Алисы и стал думать, какую пользу они бы могли принести. На площадке много разных прикольных игр (в том числе мои), но вот захотелось сделать рабочий инструмент, который действительно нужен в голосовом исполнении, а не просто копирует существующего чат-бота с кнопками.  Голос актуален тогда, когда либо руки заняты, либо нужно выполнять много последовательных операций, особенно на экране телефона. Так возникла идея навыка, который по одной команде выделяет из текста указание на дату и время и добавляет событие с этим текстом в Google Calendar. Например, если пользователь скажет Послезавтра в 11 вечера будет красивый закат, то в календарь на послезавтра в 23:00 уходит строка Будет красивый закат. Под катом описание алгоритма работы библиотеки Hors: распознавателя даты и времени в естественной русской речи. Хорс — это славянский бог солнца. Github | NuGet dateparser на Python
Заявлена поддержка русского языка, но на русском библиотека не справляется даже с базовыми вещами: chronic на Ruby
Известная рубистам библиотека, которая, говорят, делает свою работу отлично. Но поддержка русского не обнаружена. Google Assistant
Раз мы говорим о добавлении в Google Calendar голосом, спрашивается, а почему бы не воспользоваться Ассистентом? Можно, но идея проекта в том, чтобы делать работу одной фразой без лишних телодвижений и нажатий. И чтобы делать это надёжно. В Ассистенте пока есть проблемы с этим:  Я писал библиотеку на .NETStandard 2.0 (C#). Поскольку изначально библиотека делалась под Алису, то все числительные в тексте полагаются именно числами, потому что Алиса делает такое преобразование автоматически. Если у вас числительные строками, то здесь есть замечательная статья пользователя Doomer3D о том, как превращать слова в числа. При работе с голосовым вводом самым надёжным способом отличать правильные слова от неправильных является использование словоформ. Подробнее мы с другом говорили об этом в видеоуроке для Школы Алисы от Яндекса. В данной же статье я оставлю за кадром работу со словоформами. В коде будут встречаться вот такие конструкции: Эта функция возвращает true, если слово t является любой формой одного из трёх последующих слов, например: прошлого, прошедшие, предыдущих. Чтобы понять, как вылавливать в тексте даты, нужно перечислить типовые фразы, которые мы используем для обозначения дат в реальных беседах. Например:  — завтра пойду гулять
 — завтра вечером пойду гулять
 — в следующий четверг иду в кино
 — в следующий четверг в 9 вечера иду в кино
 — 21 марта в 10 утра совещание Мы видим, что слова в целом делятся на три вида:  С первыми и последними всё понятно, но со вторыми есть сложности. Изначальная версия алгоритма была ужасным спагетти-кодом с большим числом if, потому что я пытался учесть все возможные комбинации и перестановки нужных мне слов, но потом придумал вариант получше. Дело в том, что человечество уже изобрело систему, позволяющую быстро и просто учитывать перестановки и комбинации символов: движок регулярных выражений. Разобьём строку на токены, убрав знаки препинания и приведя всё к нижнему регистру. После чего заменим каждый непустой токен на какой-то единичный символ, чтобы работать с регулярными выражениями было проще. Вот что получается для строк, которые мы упоминали: — О, ужас! — скажете вы, — Стало только хуже! Такую абракадабру и человек понять не сможет. Да, пришлось пойти на некоторый компромисс между удобством чтения человеком и удобством работы с регулярками, ниже увидите, как именно. Дальше применяем паттерн, который называется цепочка обязанностей: входной массив данных подаётся последовательно в разные обработчики, которые могут его менять или не менять и передавать дальше. Обработчик я назвал Recognizer и сделал по такому обработчику на каждый вариант типовой (как мне показалось) фразы, относящейся к дате и времени.  Recognizer ищет заданный regex-паттерн в строке и запускает для каждого найденного соответствия функцию обработки, которая может изменять входную строку и добавлять пойманные даты в специальный массив. Причем, обработчики нужно вызывать в правильном порядке: от более "уверенных" к менее. Например, в самом начале нужно запустить обработчики, связанные со "строгими" словами, которые точно относятся к датам: названия дней, месяцев, слова вроде "завтра", "послезавтра" и так далее. А в конце обработчики, которые пытаются по необработанным остаткам определить, а может ли там быть ещё что-то, связанное с датой. Например: Тут начинает проявляться прелесть применения регулярных выражений. Достаточно простым образом мы обозначили сложную последовательность токенов: ненулевое количество неотрицательных чисел меньше 1901 идут друг за другом, возможно, разделяясь союзом "и", а за ними идёт либо название месяца, либо слово "число". Причем, в мэтч-группы мы сразу ловим конкретные элементы, если они есть, и не ловим, если их нет, и из этого набора составляем конечную дату. В коде ниже будет пока что непонятный участок, связанный с функциями Fix, FixPeriod, к этому мы перейдем в конце статьи. Тут важно отметить, что иногда просто факт совпадения с регуляркой недостаточен, и нужно ещё добавлять какую-то логику в обработчик. Хотя, можно было сделать на такие случаи два отдельных обработчика, но мне показалось это излишеством. В итоге я мэтчу и начальное "через" и конечное "спустя/назад", но код обработчика начинается с проверки: Где ^ это исключающее ИЛИ. Кстати, для работы таких вот функций в движок нужно передавать текущую дату пользователя. Не могу ещё раз не отметить удобство подхода с регулярками. Опять же простым выражением мы обозначали сразу варианты: пользователь называет число, похожее на год, но без слова "год", либо пользователь называет двузначное число (которое может быть и датой), но добавляет слово "год", и появляется поддержка выражений "в 18 году", впрочем, имеется ли ввиду 1918 или 2018 мы не знаем, поэтому полагаем, что 2018. Самое сложное выражение в моей подборке отвечает за варианты фраз для обозначения времени суток, от строгой в 9 часов 30 минут до четверть 11 вечера Система модульная, подразумевается, что можно добавлять/удалять обработчики, менять их порядок и так далее. Я сделал 11 таких (сверху вниз в порядке применения): 1 Этот обработчик заменяет слово "выходной" на "суббота" и "выходные" на "суббота и воскресенье", чтобы передать обработчику по названию дней Окей, мы определили некоторые элементарные токены даты-времени. Но теперь нам ещё нужно каким-то образом учесть все комбинации всех видов токенов: пользователь может сначала назвать день, а потом время. Или назвать месяц и день. Если он скажет только время, то, вероятно, речь идёт про сегодня, и так далее. Для этого придумаем концепцию фиксации.  Фиксация в нашем случае — это битовая маска внутри каждого токена даты-времени, которая показывает, какие элементы даты и времени в нём заданы. TimeUncertain — фиксация для обозначения времени, после которого может следовать уточнение, например вечера/утра. Нельзя сказать 18 часов утра, но можно сказать 6 вечера, поэтому число 6 обладает как бы меньшей "уверенностью" в том, какое время оно обозначает, чем число 18. Дальше воспользуемся двумя правилами:  Таким образом, если пользователь говорит в понедельник в 9 вечера, то токен в понедельник, где задан только день, объединяется с токеном в 9 вечера, где задано время. Но если он скажет в марте 10 и 15 числа, то последний токен 15 числа просто займёт месяц март у предыдущего токена. Объединение тривиально, а с заимствованием поступим просто. Будем называть базовой дату, для которой мы занимаем, и второстепенной вторую, у которой мы хотим что-то занять. Затем скопируем второстепенную дату и оставим только те биты, которых нет у базовой: Например, если у базовой был год, мы не будем у копии второстепенной оставлять фиксацию года (даже если он был). Если у базовой не было месяца, а у второстепенной есть — он останется у копии второстепенной. После этого проводим процесс объединения базовой даты и копии второстепенной. При объединении у нас тоже есть базовая дата и поглощаемая. Идём сверху вниз от самого большого периода (год) до самого маленького (время). Если у базовой даты нет какого-то параметра, который есть у поглощаемой, мы добавляем его в базовую из поглощаемой. Отдельно нужно учесть пару нюансов: После объединения, если у каких-то дат остались пустые биты, мы заменяем их на значения из текущей даты пользователя: например, если пользователь не назвал год, речь идёт о текущем годе, если не назвал месяц, то о текущем месяце и так далее. Разумеется в параметр "текущая дата" можно передать любой объект DateTime для гибкости. Не все моменты библиотеки удалось продумать изящно. Чтобы она работала должным образом, были добавлены следующие костыли:  Вживую получилось так: Фулстек разработчик: Vuejs, .NET