Reconocer fecha y hora en lenguaje natural


Desafío


Hola Habr! Me interesé en las habilidades para Alice y comencé a pensar qué beneficios podrían aportar. Hay muchos juegos geniales diferentes en el sitio (incluido el mío), pero quería crear una herramienta de trabajo que sea realmente necesaria en la ejecución de voz, y no solo copiar el chatbot existente con botones.


La voz es relevante cuando las manos están ocupadas o si necesita realizar muchas operaciones secuenciales, especialmente en la pantalla del teléfono. Entonces surgió la idea de una habilidad que, en un comando, extrae una indicación de la fecha y la hora del texto y agrega un evento con este texto a Google Calendar . Por ejemplo, si un usuario dice que pasado mañana a las 11 pm habrá una hermosa puesta de sol , entonces la línea habrá una hermosa puesta de sol en el calendario para pasado mañana a las 23:00.


Debajo del gato hay una descripción del algoritmo de la biblioteca Hors : un reconocedor de fecha y hora en el habla rusa natural. El caballo es el dios eslavo del sol.


Github | Nuget


Soluciones existentes


dateparser en Python
Se declara la compatibilidad con el idioma ruso, pero en ruso la biblioteca ni siquiera puede hacer frente a cosas básicas:


>>> import dateparser >>> dateparser.parse(u'13  2015 .  13:34') datetime.datetime(2015, 1, 13, 13, 34) >>> dateparser.parse(u' ') >>> dateparser.parse(u'    9 ') >>> dateparser.parse(u'13 ') datetime.datetime(2019, 10, 13, 0, 0) >>> dateparser.parse(u'13   9 ') 

crónica en Ruby
Una biblioteca conocida por los rubistas, que, dicen, hace su trabajo perfectamente. Pero no se encuentra apoyo para el ruso.


Asistente de Google
Como estamos hablando de agregar voz a Google Calendar, preguntamos, ¿por qué no usar el Asistente? Es posible, pero la idea del proyecto es hacer el trabajo en una frase sin gestos y toques innecesarios. Y para hacerlo de manera confiable. El asistente tiene problemas con esto por ahora:



Presets


Escribí una biblioteca en .NETStandard 2.0 (C #) . Dado que la biblioteca se creó originalmente para Alice, se supone que todos los números en el texto son números, porque Alice realiza esta conversión automáticamente. Si tiene números en cadenas, entonces hay un maravilloso artículo de Doomer3D sobre cómo convertir palabras en números.


Morfología


Cuando se trabaja con entrada de voz, la forma más confiable de distinguir las palabras correctas de las incorrectas es usar formas de palabras. Un amigo y yo hablamos sobre esto con más detalle en un video tutorial para la Escuela Alice de Yandex. En este artículo, dejaré el trabajo detrás de escena con formas de palabras. Tales construcciones aparecerán en el código:


 Morph.HasOneOfLemmas(t, "", "", ""); 

Esta función devuelve true si la palabra t es cualquier forma de una de las tres palabras siguientes, por ejemplo: pasado , pasado , anterior .


Teoría


Para comprender cómo capturar fechas en el texto, debe enumerar las frases típicas que usamos para indicar fechas en conversaciones reales. Por ejemplo:


- Mañana saldré a caminar
- Iré a caminar mañana por la noche
- el jueves que viene voy al cine
- El próximo jueves a las 9 de la noche voy al cine
- 21 de marzo a las 10 am reunión


Vemos que las palabras en su conjunto se dividen en tres tipos:


  1. Los que siempre se refieren a fechas y horas (nombres de meses y días)
  2. Los que se relacionan con la fecha y la hora en una determinada posición en relación con otras palabras ("día", "tarde", "siguiente", números)
  3. Los que nunca tienen fecha y hora

Con el primero y el último, todo está claro, pero con el segundo hay dificultades. La versión original del algoritmo era un terrible código de espagueti con una gran cantidad de if , porque traté de tener en cuenta todas las combinaciones y permutaciones posibles de las palabras que necesitaba, pero luego se me ocurrió una mejor opción. El hecho es que la humanidad ya ha inventado un sistema que le permite tener en cuenta rápida y fácilmente las permutaciones y combinaciones de caracteres: el motor de expresión regular.


Cocinando una línea


Rompemos la línea en tokens, eliminando los signos de puntuación y reduciendo todo a minúsculas. Después de eso, reemplazamos cada token no vacío con un solo carácter para que sea más fácil trabajar con expresiones regulares.


Token (palabra)Símbolo para reemplazar
"año"Y
nombre del mesM
nombre del día de la semanaD
volverb
"luego"l (L inferior)
"a través"yo
"día libre"W
minutoe
"hora"h
"dia"d
"semana"w
mesm
"pasado", "pasado", "anterior"s
"esto", "actual", "actual"tu
"más cercano", "futuro"y
"siguiente", "futuro"x
"pasado mañana"6 6
mañana5 5
hoy4 4
ayer3
"anteayer"2
mañanar
mediodian
"tarde"v
"noche"g
medioH
cuartoQ
c, cf
"a", "por"t
enacerca de
"número"# #
"y"N
número mayor que 1900 y menor que 99991
número no negativo menor que 19010 0
fecha ya procesada por el algoritmo@ @
cualquier otra ficha_ _

ParserExtractors.cs
internal static class ParserExtractors
{
internal static string CreatePatternFromToken(string token)
{
var t = token.ToLower().Replace("[^0-9--]", "").Trim();
if (Morph.HasOneOfLemmas(t, Keywords.Year)) return "Y";
if (Morph.HasOneOfLemmas(t, Keywords.Months().ToArray())) return "M";
if (Morph.HasOneOfLemmas(t, Keywords.DaysOfWeek().ToArray())) return "D";
if (Morph.HasOneOfLemmas(t, Keywords.PreviousPostfix)) return "b";
if (Morph.HasOneOfLemmas(t, Keywords.AfterPostfix)) return "l";
if (Morph.HasOneOfLemmas(t, Keywords.After)) return "i";
if (Morph.HasOneOfLemmas(t, Keywords.Holiday)) return "W";
var p = PeriodFromToken(t);
switch (p)
{
case Period.Minute:
return "e";
case Period.Hour:
return "h";
case Period.Day:
return "d";
case Period.Week:
return "w";
case Period.Month:
return "m";
}
var r = RelativeModeFromToken(t);
switch (r)
{
case RelativeMode.Previous:
return "s";
case RelativeMode.Current:
return "u";
case RelativeMode.CurrentNext:
return "y";
case RelativeMode.Next:
return "x";
}
var n = NeighbourDaysFromToken(t);
if (n > int.MinValue)
{
return (n + 4).ToString();
}
var d = DaytimeFromToken(t);
switch (d)
{
case DayTime.Morning:
return "r";
case DayTime.Noon:
return "n";
case DayTime.Day:
return "a";
case DayTime.Evening:
return "v";
case DayTime.Night:
return "g";
}
var pt = PartTimeFromToken(t);
switch (pt)
{
case PartTime.Quarter:
return "Q";
case PartTime.Half:
return "H";
}
if (int.TryParse(t, out var c))
{
if (c < 0 || c > 9999) return "_";
if (c > 1900) return "1";
return "0";
}
if (Morph.HasOneOfLemmas(t, Keywords.TimeFrom)) return "f";
if (Morph.HasOneOfLemmas(t, Keywords.TimeTo)) return "t";
if (Morph.HasOneOfLemmas(t, Keywords.TimeOn)) return "o";
if (Morph.HasOneOfLemmas(t, Keywords.DayInMonth)) return "#";
if (t == "") return "N";
return "_";
}
private static PartTime PartTimeFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Quarter)) return PartTime.Quarter;
if (Morph.HasOneOfLemmas(t, Keywords.Half)) return PartTime.Half;
return PartTime.None;
}
private static DayTime DaytimeFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Noon)) return DayTime.Noon;
if (Morph.HasOneOfLemmas(t, Keywords.Morning)) return DayTime.Morning;
if (Morph.HasOneOfLemmas(t, Keywords.Evening)) return DayTime.Evening;
if (Morph.HasOneOfLemmas(t, Keywords.Night)) return DayTime.Night;
if (Morph.HasOneOfLemmas(t, Keywords.DaytimeDay)) return DayTime.Day;
return DayTime.None;
}
private static Period PeriodFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Year)) return Period.Year;
if (Morph.HasOneOfLemmas(t, Keywords.Month)) return Period.Month;
if (Morph.HasOneOfLemmas(t, Keywords.Week)) return Period.Week;
if (Morph.HasOneOfLemmas(t, Keywords.Day)) return Period.Day;
if (Morph.HasOneOfLemmas(t, Keywords.Hour)) return Period.Hour;
if (Morph.HasOneOfLemmas(t, Keywords.Minute)) return Period.Minute;
return Period.None;
}
private static int NeighbourDaysFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Tomorrow)) return 1;
if (Morph.HasOneOfLemmas(t, Keywords.Today)) return 0;
if (Morph.HasOneOfLemmas(t, Keywords.AfterTomorrow)) return 2;
if (Morph.HasOneOfLemmas(t, Keywords.Yesterday)) return -1;
if (Morph.HasOneOfLemmas(t, Keywords.BeforeYesterday)) return -2;
return int.MinValue;
}
internal static RelativeMode RelativeModeFromToken(string t)
{
if (Morph.HasOneOfLemmas(t, Keywords.Current)) return RelativeMode.Current;
if (Morph.HasOneOfLemmas(t, Keywords.Next)) return RelativeMode.Next;
if (Morph.HasOneOfLemmas(t, Keywords.Previous)) return RelativeMode.Previous;
if (Morph.HasOneOfLemmas(t, Keywords.CurrentNext)) return RelativeMode.CurrentNext;
return RelativeMode.None;
}
}

Keywords.cs
public class Keywords
{
public static readonly string[] After = {""};
public static readonly string[] AfterPostfix = {""};
public static readonly string[] PreviousPostfix = {""};
public static readonly string[] Next = {"", ""};
public static readonly string[] Previous = {"", "", ""};
public static readonly string[] Current = {"", "", ""};
public static readonly string[] CurrentNext = {"", ""};
public static readonly string[] Today = {""};
public static readonly string[] Tomorrow = {""};
public static readonly string[] AfterTomorrow = {""};
public static readonly string[] Yesterday = {""};
public static readonly string[] BeforeYesterday = {""};
public static readonly string[] Holiday = {""};
public static readonly string[] Second = {"", ""};
public static readonly string[] Minute = {"", ""};
public static readonly string[] Hour = {"", ""};
public static readonly string[] Day = {""};
public static readonly string[] Week = {""};
public static readonly string[] Month = {"", ""};
public static readonly string[] Year = {""};
public static readonly string[] Noon = {""};
public static readonly string[] Morning = {""};
public static readonly string[] Evening = {""};
public static readonly string[] Night = {""};
public static readonly string[] Half = {"", ""};
public static readonly string[] Quarter = {""};
public static readonly string[] DayInMonth = {""};
public static readonly string[] January = {"", ""};
public static readonly string[] February = {"", ""};
public static readonly string[] March = {"", ""};
public static readonly string[] April = {"", ""};
public static readonly string[] May = {"", ""};
public static readonly string[] June = {"", ""};
public static readonly string[] July = {"", ""};
public static readonly string[] August = {"", ""};
public static readonly string[] September = {"", "", ""};
public static readonly string[] October = {"", ""};
public static readonly string[] November = {"", "", ""};
public static readonly string[] December = {"", ""};
public static readonly string[] Monday = {"", ""};
public static readonly string[] Tuesday = {"", ""};
public static readonly string[] Wednesday = {"", ""};
public static readonly string[] Thursday = {"", ""};
public static readonly string[] Friday = {"", ""};
public static readonly string[] Saturday = {"", ""};
public static readonly string[] Sunday = {"", ""};
public static readonly string[] DaytimeDay = {"", ""};
public static readonly string[] TimeFrom = {"", ""};
public static readonly string[] TimeTo = {"", ""};
public static readonly string[] TimeOn = {""};
public static List<string[]> Months()
{
return new List<string[]>
{
January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December
};
}
public static List<string[]> DaysOfWeek()
{
return new List<string[]>
{
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
};
}
public List<string> AllValues()
{
var values = new List<string>();
GetType()
.GetFields(BindingFlags.Static | BindingFlags.Public)
.ToList()
.ForEach(f =>
{
var words = (string[]) f.GetValue(null);
words.ToList().ForEach(values.Add);
});
return values;
}
}
view raw Keywords.cs hosted with ❤ by GitHub

Esto es lo que sucede para las líneas que mencionamos:


CadenaResultado
Iré a caminar mañana5__
mañana por la noche saldré a caminar5v__
el jueves que viene voy al cinefxD_f_
el próximo jueves a las 9 pm voy al cinefxDf0v_f_
21 de marzo a las 10 am reunión0Mf0r_

Reconocimiento


- ¡Oh horror! - Dices, - ¡Solo empeoró! Tal galimatías y una persona no puede entender. Sí, tuve que hacer un compromiso entre la conveniencia de leer por una persona y la conveniencia de trabajar con clientes habituales, vea a continuación exactamente cómo.


Luego aplicamos un patrón llamado cadena de tareas : la matriz de datos de entrada se alimenta secuencialmente a diferentes procesadores, que pueden o no cambiarla y transmitirla más. Llamé al manejador Recognizer e hice un manejador para cada variante de una frase típica (como me pareció) relacionada con la fecha y la hora.


Recognizer busca un patrón de expresión regular dado en una cadena y lanza una función de procesamiento para cada coincidencia encontrada que puede modificar la cadena de entrada y agregar fechas capturadas a una matriz especial.


Recognizer.cs
public abstract class Recognizer
{
public void ParseTokens(DatesRawData data, DateTime userDate)
{
ForAllMatches(data.GetPattern, pattern: GetRegexPattern(), action: m => ParseMatch(data, m, userDate));
}
public static void ForAllMatches(Func<string> input, string pattern, Predicate<Match> action, bool reversed = false)
{
var matches = Regex.Matches(input.Invoke(), pattern);
if (matches.Count == 0)
{
return;
}
var match = reversed ? matches[matches.Count - 1] : matches[0];
var indexesToSkip = new HashSet<int>();
while (match != null && match.Success)
{
var text = input.Invoke();
var matchIndex = reversed ? text.Length - match.Index : match.Index;
if (!action.Invoke(match))
{
indexesToSkip.Add(matchIndex);
}
match = null;
text = input.Invoke();
matches = Regex.Matches(text, pattern);
for (var i = 0; i < matches.Count; i++)
{
var index = reversed ? matches.Count - i - 1 : i;
matchIndex = reversed ? text.Length - matches[index].Index : matches[index].Index;
if (!indexesToSkip.Contains(matchIndex))
{
match = matches[index];
break;
}
}
}
}
protected abstract string GetRegexPattern();
protected abstract bool ParseMatch(DatesRawData data, Match match, DateTime userDate);
}
view raw Recognizer.cs hosted with ❤ by GitHub

Además, los manejadores deben llamarse en el orden correcto: de más "seguro" a menos. Por ejemplo, al principio, debe ejecutar controladores asociados con palabras "estrictas" que se relacionen exactamente con las fechas: los nombres de días, meses, palabras como "mañana", "pasado mañana", etc. Y al final, los controladores que intentan determinar por saldos brutos si puede haber algo más relacionado con la fecha.


Por ejemplo:


Fecha y mes


 "((0N?)+)(M|#)"; // 24, 25, 26...  27 / 

Aquí el encanto de usar expresiones regulares comienza a manifestarse. De una manera bastante simple, hemos identificado una secuencia compleja de tokens: un número distinto de cero de números no negativos menos de 1901 se suceden, posiblemente separados por la unión "y", y el nombre del mes o la palabra "número" los sigue.


Además, en el grupo de coincidencias capturamos inmediatamente elementos específicos, si los hay, y no capturamos si no hay ninguno, y a partir de este conjunto componimos la fecha final. En el siguiente código, habrá una sección incomprensible relacionada con las funciones Fix , FixPeriod , pasaremos a esto al final del artículo.


DaysMonthRecognizer.cs
public class DaysMonthRecognizer : Recognizer
{
protected override string GetRegexPattern()
{
return "((0N?)+)(M|#)"; // 24, 25, 26... 27 /
}
protected override bool ParseMatch(DatesRawData data, Match match, DateTime userDate)
{
var dates = new List<AbstractPeriod>();
var monthFixed = false;
// parse month
var mStr = data.Tokens[match.Index + match.Groups[1].Length].Value;
var month = ParserUtils.FindIndex(mStr, Keywords.Months()) + 1;
if (month == 0) month = userDate.Month; // # instead M
else monthFixed = true;
// create dates
for (var i = match.Index; i < match.Index + match.Groups[1].Length; i++)
{
var t = data.Tokens[i];
int.TryParse(t.Value, out var day);
if (day <= 0) continue; // this is "AND" or other token
// current token is number, store it as a day
var period = new AbstractPeriod
{
Date = new DateTime(
userDate.Year,
month,
ParserUtils.GetDayValidForMonth(userDate.Year, month, day)
)
};
// fix from week to day, and year/month if it was
period.Fix(FixPeriod.Week, FixPeriod.Day);
if (monthFixed) period.Fix(FixPeriod.Month);
// store
dates.Add(period);
// compare with last if month not fixed
if (!monthFixed && dates.Count > 0 && dates.Last().Date < period.Date)
{
period.Date = new DateTime(
userDate.Year,
month + 1,
ParserUtils.GetDayValidForMonth(userDate.Year, month + 1, day)
);
}
}
// replace all scanned tokens
data.ReplaceTokensByDates(match.Index, match.Length, dates.ToArray());
return true;
}
}

Lapso de tiempo


 "(i)?((0?[Ymwdhe]N?)+)([bl])?"; // ()     2  4  10  (/) 

Es importante tener en cuenta que a veces solo el hecho de la coincidencia con la temporada regular no es suficiente, y aún necesita agregar algo de lógica al controlador. Aunque, fue posible hacer dos manejadores separados para tales casos, pero me pareció un exceso. Como resultado, hago coincidir tanto el "a través" inicial como el "posterior / posterior" final, pero el código del controlador comienza con una comprobación:


 if (match.Groups[1].Success ^ match.Groups[4].Success) 

Donde ^ es el OR exclusivo.


TimeSpanRecognizer.cs
public class TimeSpanRecognizer : Recognizer
{
protected override string GetRegexPattern()
{
return "(i)?((0?[Ymwdhe]N?)+)([bl])?"; // () 2 4 10 (/)
}
protected override bool ParseMatch(DatesRawData data, Match match, DateTime userDate)
{
if (match.Groups[1].Success ^ match.Groups[4].Success)
{
// if "after" of "before", but not both and not neither
var letters = match.Groups[2].Value.Select(s => s.ToString()).ToList();
var lastNumber = 1;
var tokenIndex = match.Groups[2].Index;
var direction = 1; // moving to the future
if (match.Groups[4].Success && match.Groups[4].Value == "b")
{
direction = -1; // "before"
}
var date = new AbstractPeriod
{
SpanDirection = direction,
};
// save current day to offser object
var offset = new DateTimeOffset(userDate);
letters.ForEach(l =>
{
switch (l)
{
case "N": // "and", skip it
break;
case "0": // number, store it
int.TryParse(data.Tokens[tokenIndex].Value, out lastNumber);
break;
case "Y": // year(s)
offset = offset.AddYears(direction * lastNumber);
date.FixDownTo(FixPeriod.Month);
lastNumber = 1;
break;
case "m": // month(s)
offset = offset.AddMonths(direction * lastNumber);
date.FixDownTo(FixPeriod.Week);
lastNumber = 1;
break;
case "w": // week(s)
offset = offset.AddDays(7 * direction * lastNumber);
date.FixDownTo(FixPeriod.Day);
lastNumber = 1;
break;
case "d": // day(s)
offset = offset.AddDays(direction * lastNumber);
date.FixDownTo(FixPeriod.Day);
lastNumber = 1;
break;
case "h": // hour(s)
offset = offset.AddHours(direction * lastNumber);
date.FixDownTo(FixPeriod.Time);
lastNumber = 1;
break;
case "e": // minute(s)
offset = offset.AddMinutes(direction * lastNumber);
date.FixDownTo(FixPeriod.Time);
break;
}
tokenIndex++;
});
// set date
date.Date = new DateTime(offset.DateTime.Year, offset.DateTime.Month, offset.DateTime.Day);
if (date.IsFixed(FixPeriod.Time))
date.Time = new TimeSpan(offset.DateTime.Hour, offset.DateTime.Minute, 0);
date.Span = offset - userDate;
// remove and insert
data.ReplaceTokensByDates(match.Index, match.Length, date);
return true;
}
return false;
}
}

Por cierto, para que tales funciones funcionen, el motor necesita transferir la fecha actual del usuario.


Año


 "(1)Y?|(0)Y"; // [] 15 /2017 () 

No puedo volver a notar la conveniencia del enfoque con los clientes habituales. Nuevamente, con una expresión simple, indicamos opciones de inmediato: el usuario nombra un número similar a un año, pero sin la palabra "año", o el usuario nombra un número de dos dígitos (que puede ser una fecha), pero agrega la palabra "año" y admite las expresiones "en 18 años ", sin embargo, no sabemos si significa 1918 o 2018, por lo que creemos que 2018.


YearRecognizer.cs
public class YearRecognizer : Recognizer
{
protected override string GetRegexPattern()
{
return "(1)Y?|(0)Y"; // [] 15 /2017 ()
}
protected override bool ParseMatch(DatesRawData data, Match match, DateTime userDate)
{
// just year number
int.TryParse(data.Tokens[match.Index].Value, out var n);
var year = ParserUtils.GetYearFromNumber(n);
// insert date
var date = new AbstractPeriod
{
Date = new DateTime(year, 1, 1)
};
date.Fix(FixPeriod.Year);
// remove and insert
data.ReplaceTokensByDates(match.Index, match.Length, date);
return true;
}
}
view raw YearRecognizer.cs hosted with ❤ by GitHub

Tiempo


 "([rvgd])?([fot])?(Q|H)?(h|(0)(h)?)((0)e?)?([rvgd])?"; // (//) (/) /9 () (30 ()) (///) 

La expresión más difícil en mi colección es responsable de que las frases indiquen la hora del día, desde estrictas a las 9.30 a.m. a un cuarto a las 11 p.m.


TimeRecognizer.cs
public class TimeRecognizer : Recognizer
{
protected override string GetRegexPattern()
{
return "([rvgd])?([fot])?(Q|H)?(h|(0)(h)?)((0)e?)?([rvgd])?"; // (//) (/) /9 () (30 ()) (///)
}
protected override bool ParseMatch(DatesRawData data, Match match, DateTime userDate)
{
// determine if it is time
if (
match.Groups[5].Success //
|| match.Groups[6].Success // ""
|| match.Groups[4].Success // ""
|| match.Groups[1].Success // "///"
|| match.Groups[9].Success //
)
{
if (!match.Groups[5].Success)
{
// no number in phrase
var partOfDay = match.Groups[9].Success
? match.Groups[9].Value
: match.Groups[1].Success
? match.Groups[1].Value
: "";
// no part of day AND no "from" token in phrase, quit
if (partOfDay != "d" && partOfDay != "g" && !match.Groups[2].Success)
{
return false;
}
}
// hours and minutes
var hours = match.Groups[5].Success ? int.Parse(data.Tokens[match.Groups[5].Index].Value) : 1;
if (hours >= 0 && hours <= 23)
{
// try minutes
var minutes = 0;
if (match.Groups[8].Success)
{
var m = int.Parse(data.Tokens[match.Groups[8].Index].Value);
if (m >= 0 && m <= 59) minutes = m;
}
else if (match.Groups[3].Success && hours > 0)
{
switch (match.Groups[3].Value)
{
case "Q": // quarter
hours--;
minutes = 15;
break;
case "H": // half
hours--;
minutes = 30;
break;
}
}
// create time
var date = new AbstractPeriod();
date.Fix(FixPeriod.TimeUncertain);
if (hours > 12) date.Fix(FixPeriod.Time);
// correct time
if (hours <= 12)
{
var part = "d"; // default
if (match.Groups[9].Success || match.Groups[1].Success)
{
// part of day
part = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[9].Value;
date.Fix(FixPeriod.Time);
}
else
{
date.Fix(FixPeriod.TimeUncertain);
}
switch (part)
{
case "d": // day
if (hours <= 4) hours += 12;
break;
case "v": // evening
if (hours <= 11) hours += 12;
break;
case "g": // night
hours += 12;
break;
}
if (hours == 24) hours = 0;
}
date.Time = new TimeSpan(hours, minutes, 0);
// remove and insert
var toTime = data.Tokens[match.Index];
data.ReplaceTokensByDates(match.Index, match.Length, date);
if (match.Groups[2].Success && match.Groups[2].Value == "t")
{
// return "to" to correct period parsing
data.ReturnTokens(match.Index, "t", toTime);
}
return true;
}
}
return false;
}
}
view raw TimeRecognizer.cs hosted with ❤ by GitHub

Todos los manejadores en orden de aplicación


El sistema es modular, se entiende que puede agregar / quitar controladores, cambiar su orden, etc. Hice 11 de estos (de arriba a abajo en orden de aplicación):


ManejadorRegexEjemplo de línea
HolidaysRecognizer.cs 1Wdía libre , fin de semana
DatesPeriodRecognizer.csf?(0)[ot]0(M|#)del 26 de enero al 27 de enero / fecha
DaysMonthRecognizer.cs((0N?)+)(M|#)24, 25, 26 de enero ... y 27 de enero / fecha
MonthRecognizer.cs([usxy])?M[en] (pasado / este / próximo) marzo
RelativeDayRecognizer.cs[2-6]anteayer, ayer, hoy, mañana, pasado mañana
TimeSpanRecognizer.cs(i)?((0?[Ymwdhe]N?)+)([bl])?(in) año y mes y 2 días 4 horas 10 minutos (más tarde / atrás)
YearRecognizer.cs(1)Y?|(0)Y[en] 15 años / 2017 (año)
RelativeDateRecognizer.cs([usxy])([Ymwd])[on / on] siguiente / este / año anterior / mes / semana / día
DayOfWeekRecognizer.cs([usxy])?(D)[en] (siguiente / este / anterior) lunes
TimeRecognizer.cs([rvgd])?([fot])?(Q|H)?(h|(0)(h)?)((0)e?)?([rvgd])?(w / s / up) (medio / cuarto) hora / 9 (horas) (30 (minutos)) (mañana / día / tarde / noche)
PartOfDayRecognizer.cs(@)?f?([ravgdn])f?(@)?(fecha) (w / s) mañana / tarde / tarde / noche (w / s) (fecha)

1 "" "" "" " ",


Coser juntos


Ok, definimos algunos tokens de fecha y hora elementales. Pero ahora aún debemos tener en cuenta de alguna manera todas las combinaciones de todos los tipos de tokens: el usuario puede nombrar primero el día y luego la hora. O nombra el mes y el día. Si solo dice el tiempo, entonces probablemente sea sobre hoy, y así sucesivamente. Para hacer esto, se nos ocurre el concepto de fijación .


Una solución en nuestro caso es una máscara de bits dentro de cada token de fecha y hora, que muestra qué elementos de fecha y hora están configurados.


 public enum FixPeriod { None = 0, Time = 1, TimeUncertain = 2, Day = 4, Week = 8, Month = 16, Year = 32 } 

TimeUncertain es un compromiso para indicar el tiempo después del cual puede seguir un refinamiento, por ejemplo, en la tarde / en la mañana . No se puede decir las 18 en punto de la mañana , pero se puede decir las 6 en punto de la tarde , por lo tanto, el número 6 tiene, por así decirlo, menos "certeza" sobre qué hora significa que el número 18 .


FraseFijaciónMascara
26 de marzo de 2019año , mes , semana , den , vrm1, vrm2111100
26 númerosaño, mes, semana, den , vrm1, vrm2000100
lunesaño, mes, semana, den , vrm1, vrm2000100
el lunes que vieneaño , mes , semana , den , vrm1, vrm2111100
la semana que vieneaño , mes , semana , den, vrm1, vrm2111000
a las 9 en puntoaño, mes, semana, den, vrm1 , vrm2000010
a las 9 p.m.año, mes, semana, den, vrm1 , vrm2000011

A continuación, utilizaremos dos reglas:


  • Si las fechas están cerca y no tienen partes comunes, las fusionamos en una sola.
  • Si las fechas están cerca y no las combinamos, pero una de ellas tiene bits que la segunda no tiene, entonces ocupamos los bits (y los valores correspondientes a ellos) desde el lugar donde están, hasta el que no tienen, pero solo para el mayor: es decir, no podemos tomar un día si solo se da un mes, pero podemos tomar un mes si solo se establece un día.


Por lo tanto, si el usuario habla el lunes a las 9 p.m. , entonces el token del lunes , donde solo se establece el día, se combina con el token a las 9 p.m. , donde se establece la hora. Pero si dice el 10 y 15 de marzo del día , entonces el último token del 15 del día simplemente tomará el mes de marzo del token anterior.


La unión es trivial, pero con los préstamos procederemos de manera simple. Llamaremos a la fecha base para la que ocupamos, y la segunda secundaria , de la que queremos pedir prestado algo. Luego copiamos la fecha secundaria y dejamos solo aquellos bits que la base no tiene:


 var baseDate = data.Dates[firstIndex]; var secondDate = data.Dates[secondIndex]; var secondCopy = secondDate.CopyOf(); secondCopy.Fixed &= (byte)~baseDate.Fixed; 

Por ejemplo, si la base tenía un año, no dejaremos la fijación del año para la copia del menor (incluso si lo fuera). Si la base no tenía un mes, pero el secundario lo tiene, permanecerá con la copia como secundaria. Después de eso, llevamos a cabo el proceso de combinar la fecha base y la copia secundaria.


Cuando se combinan, también tenemos una fecha base y una fecha absorbida . Vamos de arriba a abajo desde el período más grande (año) hasta el más pequeño (tiempo). Si la fecha base no tiene algún parámetro que tenga el absorbido, lo agregamos a la base del absorbido.


Collapse.cs
public static bool CanCollapse(AbstractPeriod basePeriod, AbstractPeriod coverPeriod)
{
if ((basePeriod.Fixed & coverPeriod.Fixed) != 0) return false;
return basePeriod.SpanDirection != -coverPeriod.SpanDirection || basePeriod.SpanDirection == 0;
}
public static bool CollapseTwo(AbstractPeriod basePeriod, AbstractPeriod coverPeriod)
{
if (!CanCollapse(basePeriod, coverPeriod)) return false;
// if span
if (basePeriod.SpanDirection != 0)
{
if (coverPeriod.SpanDirection != 0)
{
// if another date is Span, just add spans together
basePeriod.Span += coverPeriod.Span;
}
}
// take year if it is not here, but is in other date
if (!basePeriod.IsFixed(FixPeriod.Year) && coverPeriod.IsFixed(FixPeriod.Year))
{
basePeriod.Date = new DateTime(coverPeriod.Date.Year, basePeriod.Date.Month, basePeriod.Date.Day);
basePeriod.Fix(FixPeriod.Year);
}
// take month if it is not here, but is in other date
if (!basePeriod.IsFixed(FixPeriod.Month) && coverPeriod.IsFixed(FixPeriod.Month))
{
basePeriod.Date = new DateTime(basePeriod.Date.Year, coverPeriod.Date.Month, basePeriod.Date.Day);
basePeriod.Fix(FixPeriod.Month);
}
// week and day
if (!basePeriod.IsFixed(FixPeriod.Week) && coverPeriod.IsFixed(FixPeriod.Week))
{
// the week is in another date, check where is a day
if (basePeriod.IsFixed(FixPeriod.Day))
{
// set day of week, take date
basePeriod.Date = TakeDayOfWeekFrom(coverPeriod.Date, basePeriod.Date);
basePeriod.Fix(FixPeriod.Week);
}
else if (!coverPeriod.IsFixed(FixPeriod.Day))
{
// only week here, take it by taking a day
basePeriod.Date = new DateTime(basePeriod.Date.Year, basePeriod.Date.Month, coverPeriod.Date.Day);
basePeriod.Fix(FixPeriod.Week);
}
}
else if (basePeriod.IsFixed(FixPeriod.Week) && coverPeriod.IsFixed(FixPeriod.Day))
{
// here is a week, but day of week in other date
basePeriod.Date = TakeDayOfWeekFrom(basePeriod.Date, coverPeriod.Date);
basePeriod.Fix(FixPeriod.Week, FixPeriod.Day);
}
// day
if (!basePeriod.IsFixed(FixPeriod.Day) && coverPeriod.IsFixed(FixPeriod.Day))
{
if (coverPeriod.FixDayOfWeek)
{
// take only day of week from cover
basePeriod.Date = TakeDayOfWeekFrom(
new DateTime(
basePeriod.Date.Year, basePeriod.Date.Month,
basePeriod.IsFixed(FixPeriod.Week) ? basePeriod.Date.Day : 1
),
coverPeriod.Date,
!basePeriod.IsFixed(FixPeriod.Week)
);
basePeriod.Fix(FixPeriod.Week, FixPeriod.Day);
}
else
{
// take day from cover
basePeriod.Date = new DateTime(basePeriod.Date.Year, basePeriod.Date.Month, coverPeriod.Date.Day);
basePeriod.Fix(FixPeriod.Week, FixPeriod.Day);
}
}
// time
var timeGot = false;
if (!basePeriod.IsFixed(FixPeriod.Time) && coverPeriod.IsFixed(FixPeriod.Time))
{
basePeriod.Fix(FixPeriod.Time);
if (!basePeriod.IsFixed(FixPeriod.TimeUncertain))
{
basePeriod.Time = coverPeriod.Time;
}
else
{
if (basePeriod.Time.Hours <= 12 && coverPeriod.Time.Hours > 12)
{
basePeriod.Time += new TimeSpan(12, 0, 0);
}
}
timeGot = true;
}
if (!basePeriod.IsFixed(FixPeriod.TimeUncertain) && coverPeriod.IsFixed(FixPeriod.TimeUncertain))
{
basePeriod.Fix(FixPeriod.TimeUncertain);
if (basePeriod.IsFixed(FixPeriod.Time))
{
// take time from cover, but day part from base
var offset = coverPeriod.Time.Hours <= 12 && basePeriod.Time.Hours > 12 ? 12 : 0;
basePeriod.Time = new TimeSpan(coverPeriod.Time.Hours + offset, coverPeriod.Time.Minutes, 0);
}
else
{
basePeriod.Time = coverPeriod.Time;
timeGot = true;
}
}
// if this date is Span and we just got time from another non-span date, add this time to Span
if (timeGot && basePeriod.SpanDirection != 0 && coverPeriod.SpanDirection == 0)
{
basePeriod.Span += basePeriod.SpanDirection == 1 ? basePeriod.Time : -basePeriod.Time;
}
// set tokens edges
basePeriod.Start = Math.Min(basePeriod.Start, coverPeriod.Start);
basePeriod.End = Math.Max(basePeriod.End, coverPeriod.End);
return true;
}
view raw Collapse.cs hosted with ❤ by GitHub

Por separado, debe tener en cuenta un par de matices:


  • La fecha base puede tener un día fijo, pero no una semana. En este caso, debe tomar una semana desde la fecha de absorción, pero el día de la semana desde la base.
  • Sin embargo, si la fecha absorbida no tiene un día fijo, pero una semana es fija, debemos quitarle el día (es decir, año + mes + fecha) y establecer la semana de esta manera, porque no hay una entidad separada "Semana" en el objeto DateTime .
  • Si TimeUncertain fija en la fecha base, mientras que Time absorbe en la fecha absorbida y el número de horas en la fecha absorbida es más de 12, mientras que la base tiene menos, entonces se deben agregar 12 horas a la base. Porque no puedes decir de 5 a 17 de la tarde . La hora de la fecha "incierta" no puede ser de la mitad del día, si la hora de la fecha "segura" al lado es de la otra mitad del día. La gente no dice eso. Si dijimos la frase de 5 a.m. a 5 p.m. , ambas fechas tienen un horario "seguro" y no hay problema.

Después de combinar, si algunas fechas tienen bits vacíos, los reemplazamos con valores de la fecha actual del usuario: por ejemplo, si el usuario no nombró el año, estamos hablando del año actual, si no nombró el mes, entonces el mes actual y así sucesivamente. Por supuesto, cualquier objeto DateTime se puede pasar al parámetro "fecha actual" para mayor flexibilidad.


Muletas


No todos los momentos de la biblioteca fueron pensados ​​con gracia. Para que funcione correctamente, se agregaron las siguientes muletas:


  • Las fechas se combinan por separado para los tokens que comienzan con "b \ s \ co" y por separado para los tokens que comienzan con "b \ to \ on". Porque, si el usuario nombró el período, entonces es imposible combinar tokens desde la fecha de inicio y la fecha de finalización.
  • No comencé a introducir un nivel de fijación separado para el día de la semana, porque se necesita exactamente en un lugar: donde está claramente dado por una palabra con el nombre del día de la semana. Hizo una bandera para esto.
  • Una ejecución separada combina fechas que están a cierta distancia entre sí. Esto está controlado por el parámetro collapseDistance , que por defecto es 4 tokens. Es decir, por ejemplo, la frase funcionará: pasado mañana, una reunión con un amigo a las 12 . Pero no funcionará: pasado mañana conoceré a mi querido y maravilloso amigo a las 12 .

Resumen



  • Puedes usar la biblioteca en tus proyectos. Ella ya maneja muchas opciones, pero la refino y refactorizo. Las solicitudes de extracción con pruebas son bienvenidas. En general, inventa una prueba que suene realista (como la gente dice en la vida), pero que rompe la biblioteca.
  • La demostración en vivo en .NET Fiddle también funciona, aunque el código supuestamente está subrayado con errores, pero comienza. En la parte inferior de la consola, puede ingresar frases en ruso, sin olvidar que los números deben ser números.
  • La misma demostración que una aplicación de consola

Vive resultó así:


Source: https://habr.com/ru/post/471204/


All Articles